From 0ccd3ed4638f5ae10771cc74147fcfb8a92a7e2d Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 13:34:45 +0800 Subject: [PATCH 001/104] feat(feedback): add a /feedback command recorded through the command plane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register a global `/feedback` command so a user can record a remark about the session without spending a model turn. `/feedback ` acknowledges; empty or whitespace-only input returns a usage error. The plugin appends no session event of its own. `dsh-commands` already writes a `command/run` / `command/done` pair for every dispatched command, carrying the verbatim text and the settled outcome, and both records are log-only and non-surface. The feedback is therefore durably in the session log and invisible to the model without this package touching the log format. Text is never parsed, so `/feedback /plan felt slow` records that literal content. Nothing consumes the records; capture is deliberately inert. New group `packages/feedback/` — no existing group owns feedback capture. Its row raises the packages/README.md word ceiling by 10, which had no headroom; one redundant sentence there was removed to offset most of the cost. --- .../2026-07-28-feedback-command.i18n.yaml | 6 + .../feature/2026-07-28-feedback-command.md | 61 ++++++ .../feature/2026-07-28-feedback-command.zh.md | 61 ++++++ docs/config-catalog.md | 3 +- docs/module-graph.md | 9 +- packages/README.i18n.yaml | 4 +- packages/README.md | 3 +- packages/README.zh.md | 3 +- packages/examples/tui-demo/package.json | 2 + packages/examples/tui-demo/src/index.ts | 4 +- .../examples/tui-demo/tests/tui-agent.spec.ts | 27 +-- packages/examples/tui-demo/tsconfig.json | 3 + packages/feedback/README.i18n.yaml | 6 + packages/feedback/README.md | 11 ++ packages/feedback/README.zh.md | 11 ++ .../command-feedback/README.i18n.yaml | 6 + packages/feedback/command-feedback/README.md | 60 ++++++ .../feedback/command-feedback/README.zh.md | 60 ++++++ .../feedback/command-feedback/package.json | 44 +++++ .../feedback/command-feedback/src/index.ts | 40 ++++ .../command-feedback/src/invariant.ts | 30 +++ .../tests/command-feedback.spec.ts | 176 ++++++++++++++++++ .../tests/loader-composition.spec.ts | 105 +++++++++++ .../feedback/command-feedback/tsconfig.json | 24 +++ pnpm-lock.yaml | 30 +++ scripts/doc-budgets.manifest.json | 2 +- tsconfig.base.json | 2 + tsconfig.host.json | 1 + 28 files changed, 774 insertions(+), 20 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-28-feedback-command.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-28-feedback-command.md create mode 100644 .agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md create mode 100644 packages/feedback/README.i18n.yaml create mode 100644 packages/feedback/README.md create mode 100644 packages/feedback/README.zh.md create mode 100644 packages/feedback/command-feedback/README.i18n.yaml create mode 100644 packages/feedback/command-feedback/README.md create mode 100644 packages/feedback/command-feedback/README.zh.md create mode 100644 packages/feedback/command-feedback/package.json create mode 100644 packages/feedback/command-feedback/src/index.ts create mode 100644 packages/feedback/command-feedback/src/invariant.ts create mode 100644 packages/feedback/command-feedback/tests/command-feedback.spec.ts create mode 100644 packages/feedback/command-feedback/tests/loader-composition.spec.ts create mode 100644 packages/feedback/command-feedback/tsconfig.json diff --git a/.agents/notes/implemented/feature/2026-07-28-feedback-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-feedback-command.i18n.yaml new file mode 100644 index 0000000000..ba56da8945 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-feedback-command.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-28-feedback-command.md +2026-07-28-feedback-command.md: ae32d3908d568c4a511e8d9e2b8cf50569fb80bf +2026-07-28-feedback-command.zh.md: f69dbf6a50161e7f5048b76be46bc4063f9e757a diff --git a/.agents/notes/implemented/feature/2026-07-28-feedback-command.md b/.agents/notes/implemented/feature/2026-07-28-feedback-command.md new file mode 100644 index 0000000000..ae32d3908d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-feedback-command.md @@ -0,0 +1,61 @@ +# Agent Note: `/feedback` command + +Status: implemented + +English | [中文](2026-07-28-feedback-command.zh.md) + +## Problem + +A user who notices something wrong mid-session has nowhere to put that observation. Telling the model wastes a turn, changes the conversation the user was having, and buries the remark in derived history where no later reader can find it. Writing it outside the session loses the context that makes it meaningful — which session, at which point, against which work. + +The capture surface has to be usable at the moment of annoyance, which rules out anything requiring the user to leave the TUI, and it must not perturb the run in progress: no model tokens, no turn of work, no change to the request the user is waiting on. + +## Decision + +`@deepseek-ai/dsh-command-feedback` in `packages/feedback/command-feedback/` registers one global `feedback` command over `ctx.commands`. `/feedback ` acknowledges; bare or whitespace-only input returns a direct usage error. The handler is synchronous, injects only `commands`, and has no configuration. + +The plugin appends **no session event of its own**. `dsh-commands` already writes a `command/run` / `command/done` pair for every dispatched command, carrying the command name, the verbatim unparsed suffix, the invocation source, and the settled outcome. Those records are log-only and non-surface, so the feedback lands in the session log and stays invisible to the model without this package contributing anything to the log format. The appends start persistence's ordinary eager drain; nothing forces a flush, so the acknowledgement reports that the entry is recorded in the log rather than already on disk. + +Capture is deliberately inert: nothing in this repository reads those records back. + +### Why no dedicated `session/feedback` event + +An earlier iteration declared one. It was removed because it duplicated a record the registry already writes: both would carry the same text, appended microseconds apart, and a consumer would have to decide which is authoritative. Selecting `command/run` records by command name is enough to find feedback, and it keeps this package free of the session event format entirely — no `SessionEventMap` merge, no invariant relation, no persistence catalog entry. + +The cost is that the recorded text is the raw suffix including its leading separator whitespace, and that feedback is distinguished from other commands only by name. Both are read-time concerns for a consumer that does not yet exist; neither justifies a second durable record now. + +### Why the model never sees it + +Feedback is about the session, not input to it. Injecting it as a user message would change the next model request, contradicting the requirement that recording not perturb the run, and would make the remark part of the conversation it comments on. `command/run` and `command/done` are absent from `SurfaceEventType`, so they cannot acquire a `surfaceOp` or enter derived history even by mistake. + +### Verbatim text + +Nothing is parsed. `/feedback /plan felt slow` records that literal text; the leading `/plan` is content, not a nested command. The handler trims only to decide whether any text was supplied. Control-word grammar of the kind `/goal` uses would make the corresponding literal feedback impossible to express, which is the opposite of what a capture surface is for. + +### A new group + +`packages/feedback/` is a new group because no existing one owns this. `goal/` is objective state, `session-title/` is titles, `core/` is the product spine. The group holds one package; a consumer would join it rather than forcing this one to grow. + +## Alternatives considered + +**Declare a dedicated `session/feedback` log-only event.** Implemented first, then removed. It gave feedback a first-class queryable type with pre-trimmed text, but duplicated the registry's record, added a `SessionEventMap` member and persistence-catalog entry to the frozen log format, and created two records of one act with no rule for which wins. + +**Inject feedback as a user message via `agent.inject()`.** Needs no new event type and reuses the path `/goal` mutations take. Rejected: it makes the feedback model-visible, so it enters the next request, changes the run being commented on, and consumes tokens — contradicting all three parts of the no-perturbation requirement. + +**Make `/feedback` a true no-op that records nothing.** The most literal reading of "does not do anything". Rejected because it makes the command pointless: the stated requirement was that the remark reach the session log. + +**Register the command inside an existing package** such as `packages/ui/commands`. Avoids a new group and its README pair. Rejected: `ctx.commands` is the registry, not a home for arbitrary command implementations, and the requester asked for a standalone package. + +**Parse structure out of the text** (category prefixes, severity markers). Rejected as speculative: no consumer exists to use the structure, and any control-word grammar makes the corresponding literal feedback unrecordable. Verbatim text is the widest surface a future consumer can narrow; a parsed one cannot be widened after the fact. + +**Add a model-facing tool instead of a slash command.** Rejected: feedback is a direct human observation. Routing it through the model spends a turn, lets the model paraphrase the user's words, and makes the record contingent on the model choosing to call the tool. + +## Consequences + +The TUI mounts the command unconditionally — no configuration, no dependency on the goal stack. The headless CLI, ACP, and JSON-RPC apps do not consume `ctx.commands`, so `/feedback` is unavailable there. + +This package is now small enough that its whole contract is the command definition plus one validation branch. It owns no session event, so it needs no invariant relation and cannot affect replay, forking, or crash recovery. + +Deferred: no consumer; no structured fields; no amend or withdraw, since the log is append-only and this package adds no tombstone; the recorded text is untrimmed, so a consumer trims at read time; and no explicit durability barrier, so an entry recorded immediately before a crash can be lost with any other unflushed tail. + +No snapshot accompanies this change. AGENTS.md asks for a keyless snapshot through a runnable example for product-user-visible behavior; this was skipped at the requester's explicit direction. The package tests plus a real Loader composition test over a `cordis.yml` are the whole of the evidence, alongside interactive verification in the assembled TUI. diff --git a/.agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md b/.agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md new file mode 100644 index 0000000000..f69dbf6a50 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md @@ -0,0 +1,61 @@ +# Agent Note: `/feedback` 命令 + +Status: implemented + +[English](2026-07-28-feedback-command.md) | 中文 + +## 问题 + +用户在会话中途发现问题时,没有地方记下这个观察。告诉模型会浪费一个轮次、改变用户原本进行的对话,并把这条评论埋进派生历史,使后续读者无法找到它。写到会话之外则会丢失让它有意义的上下文:属于哪个会话、处于哪个时点、针对哪项工作。 + +采集接口必须能在用户产生不满的那一刻使用,因此任何需要用户离开 TUI 的方案都不可行;它还不能扰动正在进行的运行:不消耗模型 token、不产生工作轮次、不改变用户正在等待的请求。 + +## 决策 + +位于 `packages/feedback/command-feedback/` 的 `@deepseek-ai/dsh-command-feedback` 通过 `ctx.commands` 注册一个全局 `feedback` 命令。`/feedback ` 给出确认;空输入或仅含空白的输入返回直接用法错误。处理器是同步的,只注入 `commands`,且没有任何配置。 + +该插件**不追加属于自己的会话事件**。`dsh-commands` 已经为每个已分发命令写入一对 `command/run` / `command/done`,携带命令名、原样未解析的后缀、调用来源以及结算结果。这些记录仅写入日志且非 surface,因此反馈会进入会话日志并对模型保持不可见,而本包无需向日志格式贡献任何内容。这些追加会启动持久化的常规即时排空;没有任何环节强制 flush,因此确认文本报告的是条目已记录在日志中,而非已经落盘。 + +采集刻意不产生后续动作:本仓库中没有任何代码读回这些记录。 + +### 为何不设专用的 `session/feedback` 事件 + +早先的实现声明过该事件,后来将其移除,因为它重复了注册表已经写入的记录:两者会携带相同文本、相隔极短时间先后追加,而消费方还得判断以哪一条为准。依据命令名筛选 `command/run` 记录已足以找到反馈,同时让本包完全不涉及会话事件格式——没有 `SessionEventMap` 合并、没有不变式关系、没有持久化目录条目。 + +代价是被记录的文本为原始后缀,包含其前导分隔空白;且反馈仅凭命令名与其他命令相区分。两者都属于尚不存在的消费方在读取时需要处理的问题,目前都不足以支撑再增加一条持久记录。 + +### 为何模型永不看到它 + +反馈是关于会话的,而不是会话的输入。将其作为 user 消息注入会改变下一次模型请求,与「记录不得扰动运行」的要求相冲突,也会让该评论成为它所评论的那段对话的一部分。`command/run` 与 `command/done` 不属于 `SurfaceEventType`,因此即便出错也无法获得 `surfaceOp` 或进入派生历史。 + +### 原样文本 + +不做任何解析。`/feedback /plan felt slow` 记录的就是该字面文本;开头的 `/plan` 是内容,而非嵌套命令。处理器仅为判断是否提供了文本而修剪。若采用 `/goal` 那样的控制词语法,对应的字面反馈将无法表达,这与采集接口的目的正好相反。 + +### 一个新的分组 + +`packages/feedback/` 是新分组,因为现有分组都不拥有此职责:`goal/` 负责目标状态,`session-title/` 负责标题,`core/` 是产品主干。该分组目前只有一个包;未来的消费方应加入该分组,而不是迫使这个包不断膨胀。 + +## 考虑过的替代方案 + +**声明专用的 `session/feedback` 仅日志事件。** 先实现后移除。它让反馈拥有一等的可查询类型和预先修剪的文本,但重复了注册表的记录,向已冻结的日志格式新增了一个 `SessionEventMap` 成员与持久化目录条目,并使同一行为产生两条记录而没有取舍规则。 + +**通过 `agent.inject()` 将反馈作为 user 消息注入。** 无需新增事件类型,并复用 `/goal` 变更所走的路径。已否决:它会让反馈对模型可见,从而进入下一次请求、改变正被评论的那次运行并消耗 token——与「不得扰动」要求的三个方面全部冲突。 + +**让 `/feedback` 成为真正的空操作,什么都不记录。** 这是对「什么都不做」最字面的理解。已否决:这会使命令失去意义——明确的要求是让这条评论进入会话日志。 + +**在现有包中注册该命令**,例如 `packages/ui/commands`。可省去新分组及其双语 README。已否决:`ctx.commands` 是注册表,而不是任意命令实现的归属地;且请求者明确要求独立的包。 + +**从文本中解析结构**(类别前缀、严重程度标记)。已否决,属于投机设计:目前没有消费方使用该结构,而任何控制词语法都会让对应的字面反馈无法记录。原样文本是未来消费方可以收窄的最宽接口;而已被解析的接口无法事后放宽。 + +**改为提供面向模型的工具。** 已否决:反馈是人类的直接观察。经由模型会消耗一个轮次、让模型改写用户的原话,并使记录取决于模型是否选择调用该工具。 + +## 后果 + +TUI 无条件挂载该命令:没有配置,也不依赖 goal 栈。无头 CLI、ACP 和 JSON-RPC 应用不消费 `ctx.commands`,因此 `/feedback` 在那里不可用。 + +本包现已小到其全部契约就是命令定义加一个校验分支。它不拥有任何会话事件,因此无需不变式关系,也不可能影响回放、fork 或崩溃恢复。 + +延期事项:没有消费方;没有结构化字段;不支持修改或撤回,因为日志仅追加且本包不新增 tombstone;被记录的文本未修剪,需由消费方在读取时处理;且没有显式持久化屏障,因此紧临崩溃前记录的条目可能与其他未 flush 的尾部一同丢失。 + +本次变更不附带 snapshot。AGENTS.md 要求面向产品用户的可见行为变更通过可运行示例附带无密钥 snapshot;此项按请求者的明确指示跳过。包测试连同一个基于真实 `cordis.yml` 的 Loader 组合测试即为全部证据,此外还有在组装后 TUI 中的交互验证。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 68343a6a19..2c2d68e934 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1994,7 +1994,7 @@ export interface Config { Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`JsonlCompression`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) · [`SessionReferenceConfig`](#deepseek-aidsh-session-reference) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`uiTui`](../packages/ui/tui/src/index.ts) -Source: [`packages/examples/tui-demo/src/index.ts:39`](../packages/examples/tui-demo/src/index.ts) +Source: [`packages/examples/tui-demo/src/index.ts:40`](../packages/examples/tui-demo/src/index.ts) ## `@deepseek-ai/dsh-user-approval` @@ -2219,6 +2219,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-theme` ([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts)) - `@deepseek-ai/dsh-client-ui-trajectory` ([`packages/client/ui-trajectory/src/index.ts`](../packages/client/ui-trajectory/src/index.ts)) - `@deepseek-ai/dsh-client-ui-workspace` ([`packages/client/ui-workspace/src/index.ts`](../packages/client/ui-workspace/src/index.ts)) +- `@deepseek-ai/dsh-command-feedback` — requires `commands` ([`packages/feedback/command-feedback/src/index.ts`](../packages/feedback/command-feedback/src/index.ts)) - `@deepseek-ai/dsh-command-goal` — requires `commands` · `goals` ([`packages/goal/command-goal/src/index.ts`](../packages/goal/command-goal/src/index.ts)) - `@deepseek-ai/dsh-commands` ([`packages/ui/commands/src/index.ts`](../packages/ui/commands/src/index.ts)) - `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts)) diff --git a/docs/module-graph.md b/docs/module-graph.md index 703f4c8abf..cc680d7c96 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -181,6 +181,9 @@ flowchart TD pkg_jsonrpc_demo["jsonrpc-demo"] pkg_tui_demo["tui-demo"] end + subgraph group_feedback["packages/feedback"] + pkg_command_feedback["command-feedback"] + end subgraph group_guard["packages/guard"] pkg_repeat_tool_guard["repeat-tool-guard"] end @@ -604,6 +607,8 @@ flowchart TD pkg_client_ui_goal --> pkg_client_ui_slots pkg_client_ui_goal --> pkg_goal pkg_client_ui_goal --> pkg_invariants + pkg_command_feedback --> pkg_commands + pkg_command_feedback --> pkg_invariants pkg_pty_local --> pkg_agent pkg_pty_local --> pkg_invariants pkg_pty_local --> pkg_pty @@ -928,6 +933,7 @@ flowchart TD pkg_tui_demo --> pkg_agent pkg_tui_demo --> pkg_agent_loop pkg_tui_demo --> pkg_agent_spine_demo + pkg_tui_demo --> pkg_command_feedback pkg_tui_demo --> pkg_command_goal pkg_tui_demo --> pkg_commands pkg_tui_demo --> pkg_invariants @@ -1069,6 +1075,7 @@ flowchart TD | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | +| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) | @@ -1116,6 +1123,6 @@ flowchart TD | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | -| [`tui-demo`](../packages/examples/tui-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`command-goal`](../packages/goal/command-goal), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`session-reference`](../packages/context/session-reference), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | +| [`tui-demo`](../packages/examples/tui-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`command-feedback`](../packages/feedback/command-feedback), [`command-goal`](../packages/goal/command-goal), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`session-reference`](../packages/context/session-reference), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | | [`sdk-client`](../packages/sdk/sdk-client) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/sdk-client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index ba5ab61b06..0510fee0b7 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: 7a86e0f034264d4059e75775016d8d5d84600d8d -README.zh.md: bfcba626bea2a70f5c2aa508bb2a5b8c09bb61dc +README.md: b283af83596b738deeb6fc482fb4ff18bedf8df8 +README.zh.md: 91bc90ff05b849aaeec1ce1010a0e5a45b5a402a diff --git a/packages/README.md b/packages/README.md index 7a86e0f034..b283af8359 100644 --- a/packages/README.md +++ b/packages/README.md @@ -12,6 +12,7 @@ Packages live at `packages///`; groups are containers, while names r |---|---|---| | [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable surface | | [`goal/`](goal/README.md) | Persisted same-session goal state and lifecycle | Product — stable surface | +| [`feedback/`](feedback/README.md) | Recorded human feedback | Product — stable surface | | [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface | | [`subprocess/`](subprocess/README.md) | Subprocess capability family: spawn seam + local process-tree implementation | Product — stable surface | | [`bash/`](bash/README.md) | Bash capability family: executor seam, local impl, model-facing tool | Product — stable surface | @@ -50,7 +51,7 @@ Packages live at `packages///`; groups are containers, while names r | [`support/`](support/README.md) | Support infrastructure (testkits, invariants, replay, Loader smokes) | Support — lower compatibility expectations | | [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (`Branded`, Harness home/path helpers, timeout, retention) | Support — small, stable, harness-dep-free | -Groups distinguish product API from support infrastructure. New packages join an existing group; a new group updates its README and this table. +New packages join an existing group; a new group updates its README and this table. ## Dependencies diff --git a/packages/README.zh.md b/packages/README.zh.md index bfcba626be..91bc90ff05 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -12,6 +12,7 @@ |---|---|---| | [`core/`](core/README.md) | 产品 API 主干:会话、提示词、工具、agent(智能体)服务与具体循环 | 产品:稳定表面 | | [`goal/`](goal/README.md) | 持久化的同会话 goal 状态与生命周期 | 产品:稳定表面 | +| [`feedback/`](feedback/README.md) | 记录人类对会话的反馈 | 产品:稳定表面 | | [`llm/`](llm/README.md) | LLM(大语言模型)能力系列:抽象服务 + 提供方适配器 | 产品:稳定表面 | | [`subprocess/`](subprocess/README.md) | 进程管理能力系列:spawn seam + 本地进程树实现 | 产品:稳定表面 | | [`bash/`](bash/README.md) | Bash 能力系列:执行器 seam、本地实现、面向模型的工具 | 产品:稳定表面 | @@ -50,7 +51,7 @@ | [`support/`](support/README.md) | 支持基础设施(testkit、不变式、回放、Loader 冒烟测试) | 支持:兼容性预期较低 | | [`util/`](util/README.md) | 组间共享的低层零依赖工具(`Branded`、Harness home/路径辅助函数、超时、保留策略) | 支持:小型、稳定、无 harness 依赖 | -组用于区分产品 API 与支持基础设施。新包加入现有组;新组则更新其 README 和此表。 +新包加入现有组;新组则更新其 README 和此表。 ## 依赖 diff --git a/packages/examples/tui-demo/package.json b/packages/examples/tui-demo/package.json index 50145e6c29..9c48f98511 100644 --- a/packages/examples/tui-demo/package.json +++ b/packages/examples/tui-demo/package.json @@ -32,6 +32,7 @@ "@deepseek-ai/dsh-agent-loop": "^0.0.1", "@deepseek-ai/dsh-commands": "^0.0.1", "@deepseek-ai/dsh-command-goal": "^0.0.1", + "@deepseek-ai/dsh-command-feedback": "^0.0.1", "@deepseek-ai/dsh-agent-spine-demo": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", @@ -55,6 +56,7 @@ "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-command-goal": "workspace:^", + "@deepseek-ai/dsh-command-feedback": "workspace:^", "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/examples/tui-demo/src/index.ts b/packages/examples/tui-demo/src/index.ts index c60ba94b3c..9d2f6a3bdf 100644 --- a/packages/examples/tui-demo/src/index.ts +++ b/packages/examples/tui-demo/src/index.ts @@ -1,6 +1,6 @@ /** * Full-screen terminal app: the default agent spine ({@link @deepseek-ai/dsh-agent-spine-demo}) - * plus persisted goals, human commands, JSONL persistence, keyboard-backed + * plus persisted goals, human commands including `/feedback`, JSONL persistence, keyboard-backed * user interaction, and one pre-created agent whose exact session identity the * TUI drives. Swappable adapters, executors, optional tools, and HMR stay in the leaf. This Loader plugin * intentionally exposes named exports only; a default export would hide its @@ -16,6 +16,7 @@ import { SessionId } from '@deepseek-ai/dsh-session' import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' import CommandService from '@deepseek-ai/dsh-commands' import * as commandGoal from '@deepseek-ai/dsh-command-goal' +import * as commandFeedback from '@deepseek-ai/dsh-command-feedback' import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' import SessionPersistenceJsonl, { @@ -122,6 +123,7 @@ export function composeTuiApp(ctx: Context, config: Config): void { const goals = config.goals ?? {} const persistenceRoot = config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT ctx.plugin(CommandService) + ctx.plugin(commandFeedback) if (goals !== false) ctx.plugin(commandGoal) ctx.plugin(SessionPersistenceJsonl, { root: persistenceRoot, diff --git a/packages/examples/tui-demo/tests/tui-agent.spec.ts b/packages/examples/tui-demo/tests/tui-agent.spec.ts index f647b3d9c6..aa483e7b1b 100644 --- a/packages/examples/tui-demo/tests/tui-agent.spec.ts +++ b/packages/examples/tui-demo/tests/tui-agent.spec.ts @@ -49,6 +49,7 @@ describe('dsh-tui-demo app', () => { expect(calls.map(call => call.name)).toEqual([ 'CommandService', + 'command-feedback', 'command-goal', 'SessionPersistenceJsonl', 'session-checkpoint-policy', @@ -61,14 +62,14 @@ describe('dsh-tui-demo app', () => { 'tool-ask-user', ]) expect(calls[0]?.config).toBeUndefined() - expect(calls[2]?.config).toEqual({ root: '/tmp/tui-sessions', compression: 'none' }) - expect(calls[4]?.config).toEqual({ path: join('/tmp/tui-sessions', 'session-query.db') }) - expect(calls[5]?.config).toEqual({ + expect(calls[3]?.config).toEqual({ root: '/tmp/tui-sessions', compression: 'none' }) + expect(calls[5]?.config).toEqual({ path: join('/tmp/tui-sessions', 'session-query.db') }) + expect(calls[6]?.config).toEqual({ maxReferences: 2, candidateLimit: 7, maxReferenceBytes: 1234, }) - const tuiConfig = calls[8]?.config as { sessionId: string } + const tuiConfig = calls[9]?.config as { sessionId: string } expect(tuiConfig).toMatchObject({ welcome: 'TUI ready', resumeCommand: 'dsh --resume {session}', @@ -76,7 +77,7 @@ describe('dsh-tui-demo app', () => { maxToolOutputLines: 3, }) expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/) - const spineConfig = calls[9]?.config as { + const spineConfig = calls[10]?.config as { readonly agents: Array> readonly goals: Record readonly maxParallelToolCalls: number @@ -109,11 +110,11 @@ describe('dsh-tui-demo app', () => { workspaceContext: false, }) - expect(calls[2]?.config).toEqual({ root: './.sessions' }) - expect(calls[5]?.config).toEqual({}) + expect(calls[3]?.config).toEqual({ root: './.sessions' }) + expect(calls[6]?.config).toEqual({}) // No configured welcome forwards none: the TUI banner sweeps in without a subtitle. - expect(calls[8]?.config).toEqual({ sessionId: 'persisted-session' }) - expect((calls[9]?.config as { agents: Array> }).agents[0]).toMatchObject({ + expect(calls[9]?.config).toEqual({ sessionId: 'persisted-session' }) + expect((calls[10]?.config as { agents: Array> }).agents[0]).toMatchObject({ id: 'main', resumeSessionId: 'persisted-session', }) @@ -129,12 +130,14 @@ describe('dsh-tui-demo app', () => { workspaceContext: false, }) - const tuiConfig = calls[7]?.config as { sessionId: string } + const tuiConfig = calls[8]?.config as { sessionId: string } expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/) - expect((calls[8]?.config as { agents: Array> }).agents[0]) + expect((calls[9]?.config as { agents: Array> }).agents[0]) .toMatchObject({ sessionId: tuiConfig.sessionId }) expect(calls.map(call => call.name)).not.toContain('command-goal') - expect(calls[8]?.config).toMatchObject({ goals: false }) + // `/feedback` is unconditional: disabling goals must not remove it. + expect(calls.map(call => call.name)).toContain('command-feedback') + expect(calls[9]?.config).toMatchObject({ goals: false }) }) it('has the namespace-plugin export shape so the Loader keeps its schema', () => { diff --git a/packages/examples/tui-demo/tsconfig.json b/packages/examples/tui-demo/tsconfig.json index d26d5b7da6..bfd6d5554c 100644 --- a/packages/examples/tui-demo/tsconfig.json +++ b/packages/examples/tui-demo/tsconfig.json @@ -35,6 +35,9 @@ { "path": "../../goal/command-goal" }, + { + "path": "../../feedback/command-feedback" + }, { "path": "../agent-spine-demo" }, diff --git a/packages/feedback/README.i18n.yaml b/packages/feedback/README.i18n.yaml new file mode 100644 index 0000000000..eca3b1c420 --- /dev/null +++ b/packages/feedback/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/feedback/README.md +README.md: ab7bc6f3e3a3be0c280855ff80e92c7d7a7e665e +README.zh.md: 9c050ac42aa468895c04124a76a3bce58756df0e diff --git a/packages/feedback/README.md b/packages/feedback/README.md new file mode 100644 index 0000000000..ab7bc6f3e3 --- /dev/null +++ b/packages/feedback/README.md @@ -0,0 +1,11 @@ +# feedback/ — recorded human feedback + +English | [中文](README.zh.md) + +The feedback family lets a human record a remark about the session without acting on it. Feedback is durable session-log content, separate from the model conversation and from any policy that might later read it. + +| Package | Role | ctx key | +|---|---|---| +| `command-feedback/` | Human-facing `/feedback` command recorded through the command plane | — | + +A recorded remark is log-only: it never enters the model surface or derived history, and no shipped plugin consumes it. A future consumer reads the command records from the session log rather than changing how they are captured. diff --git a/packages/feedback/README.zh.md b/packages/feedback/README.zh.md new file mode 100644 index 0000000000..9c050ac42a --- /dev/null +++ b/packages/feedback/README.zh.md @@ -0,0 +1,11 @@ +# feedback/:记录的人类反馈 + +[English](README.md) | 中文 + +feedback 家族让人类记录对会话的评价,但不据此采取任何动作。反馈属于持久的会话日志内容,与模型对话以及后续可能读取它的任何策略相互独立。 + +| 包 | 职责 | ctx 键 | +|---|---|---| +| `command-feedback/` | 面向用户的 `/feedback` 命令,通过命令平面完成记录 | 无 | + +被记录的评价仅写入日志:它绝不会进入模型 surface 或派生历史,随附插件也不会消费它。未来的消费方从会话日志中读取命令记录,而不是改变它们的采集方式。 diff --git a/packages/feedback/command-feedback/README.i18n.yaml b/packages/feedback/command-feedback/README.i18n.yaml new file mode 100644 index 0000000000..37f10ac485 --- /dev/null +++ b/packages/feedback/command-feedback/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/feedback/command-feedback/README.md +README.md: 90992b7295536a9099766910f616e640d4b4bcfe +README.zh.md: a7c4f03997cea182ed24dcfc7f309dc3bd872d5e diff --git a/packages/feedback/command-feedback/README.md b/packages/feedback/command-feedback/README.md new file mode 100644 index 0000000000..90992b7295 --- /dev/null +++ b/packages/feedback/command-feedback/README.md @@ -0,0 +1,60 @@ +# @deepseek-ai/dsh-command-feedback + +English | [中文](README.zh.md) + +Human-facing `/feedback` capture. The plugin registers one global command through [`ctx.commands`](../../ui/commands/README.md), so every composed command adapter discovers it; the shipped TUI executes it without a model turn. + +## Command contract + +| Input | Result | +|---|---| +| `/feedback ` | Acknowledge with `Feedback recorded.` The registry's `command/run` record carries the verbatim text. | +| `/feedback` | Return a direct usage error. Whitespace-only input is treated as empty. | + +Feedback text is never parsed: no truncation, case folding, or control words. Text that looks like another command, such as `/feedback /plan felt slow`, is feedback content. Repeated commands each produce their own record; nothing is replaced or merged. + +## What this plugin does and does not do + +The command records a remark and does nothing else. It appends no session event of its own, starts no model work, and no plugin in this repository reads its records. + +The record is the command registry's own `command/run` / `command/done` pairing, which [`dsh-commands`](../../ui/commands/README.md) appends for every dispatched command. Those appends start persistence's ordinary eager drain; neither the registry nor this command forces a `session/flush`, so the acknowledgement means the entry is in the log, not that it has already reached disk. `command/run` carries the command name, the verbatim unparsed suffix, and the invocation source; the paired `command/done` carries the outcome. Both are log-only and are absent from the ordered surface, from `deriveMessages()`, and from every model request. A rejected empty input still leaves that pairing, settled as `kind: 'error'`, so no entry can be mistaken for accepted feedback. + +A dedicated `session/feedback` event was considered and rejected: it would duplicate a record the registry already writes, and a consumer can select feedback by the command name it already stores. + +## Composition + +The producer injects only `commands`. A custom app mounts the registry plus this plugin: + +```yaml +- id: commands + name: '@deepseek-ai/dsh-commands' +- id: command-feedback + name: '@deepseek-ai/dsh-command-feedback' +``` + +The TUI app mounts this command unconditionally; it has no configuration and no dependency on the persisted-goal stack. The headless CLI, ACP automation, and JSON-RPC adapters do not consume `ctx.commands`, so they do not expose it. + +## Model Experience + +### Human `/feedback` capture + +#### What the model sees + +Nothing. The slash input, the recorded text, and the acknowledgement are all absent from model requests. The registry's `command/run` and `command/done` records are log-only and carry no `surfaceOp`, so they never reach the ordered surface, `deriveMessages()`, or a system prompt. Recording feedback during a turn does not change that turn's remaining requests. + +#### Token effect + +Zero direct token effect. Neither an accepted entry nor a usage error adds model tokens, in the recording turn or any later one. + +#### KV Cache effect + +Independent of the model request path. Recording appends to the session log only, leaving an already-reusable request prefix untouched. Nothing this package contributes can invalidate cache reuse. + +## Known Limitations and Deferred Work + +- **Nothing consumes the recorded feedback** — capture is deliberately inert. There is no retrieval, aggregation, export, or reporting surface, and no model-facing tool reads it; a consumer is a separate package that selects `command/run` records by command name. +- **No structured fields** — an entry is one free-text string with no category, severity, or referenced-event link, so feedback cannot be filtered by subject without re-reading its text. +- **No amend or withdraw** — the session log is append-only and this package adds no tombstone, so a mistaken entry stays recorded and can only be superseded by a later one. +- **Untrimmed text in the record** — the handler trims only to validate; `command/run` stores the raw suffix, including its leading separator whitespace, so a consumer trims at read time. +- **No explicit durability barrier** — the acknowledgement follows the append, not a flush, so an entry recorded immediately before a crash can be lost with any other unflushed tail. Feedback is not worth forcing a synchronous disk write for; a consumer that needs one awaits `ctx.sessions.flush(session)`. +- **TUI only in the shipped apps** — the headless CLI, ACP automation, and JSON-RPC adapters do not mount `ctx.commands`, so `/feedback` is unavailable there. diff --git a/packages/feedback/command-feedback/README.zh.md b/packages/feedback/command-feedback/README.zh.md new file mode 100644 index 0000000000..a7c4f03997 --- /dev/null +++ b/packages/feedback/command-feedback/README.zh.md @@ -0,0 +1,60 @@ +# @deepseek-ai/dsh-command-feedback + +[English](README.md) | 中文 + +面向用户的 `/feedback` 采集。该插件通过 [`ctx.commands`](../../ui/commands/README.md) 注册一个全局命令,因此每个已组合的命令适配器都能发现它;随附 TUI 无需模型轮次即可执行。 + +## 命令契约 + +| 输入 | 结果 | +|---|---| +| `/feedback ` | 以 `Feedback recorded.` 确认。注册表的 `command/run` 记录携带原样文本。 | +| `/feedback` | 返回一个直接用法错误。仅含空白的输入视为空输入。 | + +反馈文本从不被解析:没有截断、大小写折叠或控制词。看起来像另一个命令的文本(例如 `/feedback /plan felt slow`)就是反馈内容。重复执行命令会各自产生自己的记录,不会替换或合并。 + +## 本插件做什么、不做什么 + +该命令记录一条评价,不做别的事。它不追加属于自己的会话事件,不启动任何模型工作,本仓库中也没有任何插件读取它的记录。 + +记录来自命令注册表自身的 `command/run` / `command/done` 配对,由 [`dsh-commands`](../../ui/commands/README.md) 为每个已分发命令追加。这些追加会启动持久化的常规即时排空;注册表与本命令都不会强制 `session/flush`,因此确认文本表示条目已进入日志,而不表示它已经落盘。`command/run` 携带命令名、原样未解析的后缀以及调用来源;配对的 `command/done` 携带结果。两者都仅写入日志,不出现在有序 surface、`deriveMessages()` 以及任何模型请求中。被拒绝的空输入仍会留下该配对,并以 `kind: 'error'` 结算,因此任何条目都不会被误认为已接受的反馈。 + +曾考虑并否决了专用的 `session/feedback` 事件:它会重复注册表已经写入的记录,而消费方可以依据注册表已存储的命令名筛选反馈。 + +## 组合 + +生产方只注入 `commands`。自定义应用挂载注册表以及本插件: + +```yaml +- id: commands + name: '@deepseek-ai/dsh-commands' +- id: command-feedback + name: '@deepseek-ai/dsh-command-feedback' +``` + +TUI 应用无条件挂载此命令;它没有配置,也不依赖持久 goal 栈。无头 CLI、ACP 自动化和 JSON-RPC 适配器不消费 `ctx.commands`,因此不会暴露它。 + +## 模型体验 + +### 用户 `/feedback` 采集 + +#### 模型看到的内容 + +无。斜杠输入、被记录的文本以及确认文本都不出现在模型请求中。注册表的 `command/run` 与 `command/done` 记录仅写入日志且不携带 `surfaceOp`,因此它们绝不会进入有序 surface、`deriveMessages()` 或系统提示词。在某个轮次中记录反馈不会改变该轮次剩余的请求。 + +#### Token 影响 + +无直接 token 影响。无论是已接受的条目还是用法错误,都不会在记录所在轮次或此后任何轮次增加模型 token。 + +#### KV Cache 影响 + +与模型请求路径无关。记录只追加到会话日志,不触碰已经可复用的请求前缀。本包贡献的任何内容都不会使缓存复用失效。 + +## 已知限制与暂缓工作 + +- **没有任何消费方读取被记录的反馈**:采集刻意不产生任何后续动作。这里没有检索、聚合、导出或报告 surface,也没有面向模型的工具读取它;消费方是另一个依据命令名筛选 `command/run` 记录的独立包。 +- **没有结构化字段**:一条条目就是一个自由文本字符串,没有类别、严重程度或关联事件链接,因此无法在不重读文本的情况下按主题过滤反馈。 +- **不支持修改或撤回**:会话日志是仅追加的,本包也不新增 tombstone,因此错误的条目会一直保留在记录中,只能由后续条目取代。 +- **记录中的文本未修剪**:处理器只为校验而修剪;`command/run` 存储原始后缀,包含其前导分隔空白,因此消费方需在读取时修剪。 +- **没有显式持久化屏障**:确认文本紧随追加而非 flush,因此紧临崩溃前记录的条目可能与其他未 flush 的尾部一同丢失。为反馈强制同步写盘并不值得;需要该保证的消费方可自行等待 `ctx.sessions.flush(session)`。 +- **随附应用中只有 TUI 使用此命令**:无头 CLI、ACP 自动化和 JSON-RPC 适配器不挂载 `ctx.commands`,因此 `/feedback` 在那里不可用。 diff --git a/packages/feedback/command-feedback/package.json b/packages/feedback/command-feedback/package.json new file mode 100644 index 0000000000..6ad91d0e0d --- /dev/null +++ b/packages/feedback/command-feedback/package.json @@ -0,0 +1,44 @@ +{ + "name": "@deepseek-ai/dsh-command-feedback", + "description": "Human-facing slash command that records session feedback as a log-only event", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-commands": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@cordisjs/plugin-include": "workspace:^", + "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/feedback/command-feedback/src/index.ts b/packages/feedback/command-feedback/src/index.ts new file mode 100644 index 0000000000..7bf7cd0853 --- /dev/null +++ b/packages/feedback/command-feedback/src/index.ts @@ -0,0 +1,40 @@ +/** + * Human-facing `/feedback` command. It records a remark about the session and + * does nothing else: the command registry's own `command/run` and + * `command/done` events are the whole record, so this plugin only validates the + * input and acknowledges it. Those appends are eager but unflushed, so the + * acknowledgement reports the entry is logged, not that it reached disk. + * @module @deepseek-ai/dsh-command-feedback + */ + +import type { Context } from 'cordis' +import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands' + +export const name = 'command-feedback' +export const inject = ['commands'] + +const USAGE = 'Usage: /feedback ' + +/** + * Validate and acknowledge one feedback entry. `command/run` already carries + * the verbatim text, so no further append is needed; returning an error instead + * settles that record as `kind: 'error'` and leaves no accepted feedback. + * @param invocation - receiving agent, raw command input, and UI cancellation. + * @returns an acknowledgement, or a usage error when no feedback text was supplied. + */ +function executeFeedbackCommand(invocation: CommandInvocation): CommandResult { + if (invocation.rawInput.trim().length === 0) { + return { kind: 'error', text: `Feedback text is required. ${USAGE}` } + } + return { kind: 'success', text: 'Feedback recorded.' } +} + +/** Register the global `/feedback` command for every composed command adapter. */ +export function apply(ctx: Context): void { + ctx.commands.register({ + name: 'feedback', + description: 'record feedback about this session', + input: { hint: '' }, + handler: executeFeedbackCommand, + }) +} diff --git a/packages/feedback/command-feedback/src/invariant.ts b/packages/feedback/command-feedback/src/invariant.ts new file mode 100644 index 0000000000..72a3ead213 --- /dev/null +++ b/packages/feedback/command-feedback/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-command-feedback`. + * @module @deepseek-ai/dsh-command-feedback/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-command-feedback' + +/** Cordis companion plugin name. */ +export const name = 'command-feedback-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this command declares no session event and owns no state projection. The + * `command/run`/`command/done` pairing that records feedback belongs to `dsh-commands`. + */ +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/feedback/command-feedback/tests/command-feedback.spec.ts b/packages/feedback/command-feedback/tests/command-feedback.spec.ts new file mode 100644 index 0000000000..362bb8ce30 --- /dev/null +++ b/packages/feedback/command-feedback/tests/command-feedback.spec.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' +import CommandService from '@deepseek-ai/dsh-commands' +import SessionStore, { foldSurface, Session, SessionId } from '@deepseek-ai/dsh-session' +import * as commandFeedback from '@deepseek-ai/dsh-command-feedback' + +interface Harness { + readonly ctx: Context + readonly agent: Agent + readonly session: Session + readonly plugin: Awaited> +} + +/** Build a live idle agent over a store-owned session, as an app's spine does. */ +function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } { + const session = ctx.sessions.create(SessionId(id)) + let status: AgentStatus = 'idle' + const agent: Agent = { + id: session.id, + options: {}, + session, + ctx: new Context(), + get status() { return status }, + get acceptsNextStep() { return status === 'running' }, + send: () => {}, + followup: () => {}, + steer: () => {}, + inject: () => {}, + cancel() { status = 'idle' }, + whenIdle() { return Promise.resolve() }, + } + return { agent, session } +} + +/** Mount the real command registry and this producer. */ +async function harness(): Promise { + const ctx = new Context() + await ctx.plugin(CommandService) + await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionStore) + const plugin = await ctx.plugin(commandFeedback) + const { agent, session } = stubAgent(ctx, `command-feedback-${Math.random()}`) + ctx.agents.register(agent) + return { ctx, agent, session, plugin } +} + +/** Execute `/feedback` through the same registry boundary as a UI adapter. */ +async function run(test: Harness, suffix = ''): Promise<{ kind: string; text?: string }> { + const settled = await test.ctx.commands.execute( + test.agent, + `/feedback${suffix}`, + new AbortController().signal, + ) + if (settled === undefined) throw new Error('feedback command was not registered') + return settled.result +} + +/** The registry's durable record of each accepted command, in log order. */ +function commandRecords(session: Session): { name: string; args: string; kind: string }[] { + const runs = session.events.filter(event => event.type === 'command/run') + return runs.map((event) => { + const done = session.events.find(item => + item.type === 'command/done' && item.data.commandId === event.data.commandId) + if (done?.type !== 'command/done') throw new Error('every command/run must be paired') + return { name: event.data.name, args: event.data.args, kind: done.data.kind } + }) +} + +describe('@deepseek-ai/dsh-command-feedback registration', () => { + it('registers one global command with Loader-safe exports and disposes it', async () => { + const test = await harness() + expect(commandFeedback.name).toBe('command-feedback') + expect(commandFeedback.inject).toEqual(['commands']) + expect('default' in commandFeedback).toBe(false) + const loader = Object.create(Loader.prototype) as Loader + expect(loader.unwrapExports(commandFeedback)).toBe(commandFeedback) + + expect(test.ctx.commands.list(test.agent)).toContainEqual({ + name: 'feedback', + description: 'record feedback about this session', + input: { hint: '' }, + }) + expect(test.ctx.commands.find(test.agent, 'feedback')).toBeDefined() + + await test.plugin.dispose() + expect(test.ctx.commands.find(test.agent, 'feedback')).toBeUndefined() + }) +}) + +describe('/feedback human command', () => { + it('acknowledges feedback and leaves the registry record as its durable trace', async () => { + const test = await harness() + await expect(run(test, ' the diff view is unreadable')).resolves.toEqual({ + kind: 'success', + text: 'Feedback recorded.', + }) + expect(commandRecords(test.session)).toEqual([ + { name: 'feedback', args: ' the diff view is unreadable', kind: 'success' }, + ]) + }) + + it('adds no event of its own beyond the registry pairing', async () => { + const test = await harness() + await run(test, ' nothing else happens') + // The whole point of the command: record and do nothing. Only the + // registry's own pairing appears, and no turn of model work starts. + expect(test.session.events.map(event => event.type)).toEqual(['command/run', 'command/done']) + }) + + it('records verbatim text, including input that looks like another command', async () => { + const test = await harness() + await run(test, ' /plan felt SLOW\n\ttwice today ') + expect(commandRecords(test.session)).toEqual([ + { name: 'feedback', args: ' /plan felt SLOW\n\ttwice today ', kind: 'success' }, + ]) + }) + + it('records each entry separately without replacing earlier ones', async () => { + const test = await harness() + await run(test, ' first') + await run(test, ' second') + expect(commandRecords(test.session).map(record => record.args)).toEqual([' first', ' second']) + }) + + it('records concurrent submissions in dispatch order', async () => { + const test = await harness() + const signal = new AbortController().signal + // The shipped TUI dispatches commands fire-and-forget. + const settled = await Promise.all([ + test.ctx.commands.execute(test.agent, '/feedback first', signal), + test.ctx.commands.execute(test.agent, '/feedback second', signal), + ]) + expect(settled.map(item => item?.result)).toEqual([ + { kind: 'success', text: 'Feedback recorded.' }, + { kind: 'success', text: 'Feedback recorded.' }, + ]) + expect(commandRecords(test.session).map(record => record.args)).toEqual([' first', ' second']) + }) + + it('keeps every recorded event off the model surface and out of derived history', async () => { + const test = await harness() + await run(test, ' invisible to the model') + for (const event of test.session.events) { + expect('surfaceOp' in event).toBe(false) + expect(test.session.deriveEventMessage(event)).toBeNull() + } + expect(foldSurface(test.session.events).nodes).toEqual([]) + expect(test.session.surface.nodes).toEqual([]) + expect(test.session.deriveMessages()).toEqual([]) + }) + + it('rejects empty and whitespace-only input as a failed command record', async () => { + const test = await harness() + const expected = { + kind: 'error', + text: 'Feedback text is required. Usage: /feedback ', + } + await expect(run(test)).resolves.toEqual(expected) + await expect(run(test, ' \n\t ')).resolves.toEqual(expected) + // Rejected input still leaves the registry's own pairing, settled as an + // error, so no entry is mistaken for accepted feedback. + expect(commandRecords(test.session).map(record => record.kind)).toEqual(['error', 'error']) + }) + + it('records nothing when dispatch rejects an already-cancelled request', async () => { + const test = await harness() + const controller = new AbortController() + controller.abort(new Error('user cancelled the command')) + await expect(test.ctx.commands.execute(test.agent, '/feedback too late', controller.signal)) + .rejects.toThrow('user cancelled the command') + expect(test.session.events).toEqual([]) + }) +}) diff --git a/packages/feedback/command-feedback/tests/loader-composition.spec.ts b/packages/feedback/command-feedback/tests/loader-composition.spec.ts new file mode 100644 index 0000000000..9aa206f9ad --- /dev/null +++ b/packages/feedback/command-feedback/tests/loader-composition.spec.ts @@ -0,0 +1,105 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import Include from '@cordisjs/plugin-include' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' +import CommandService from '@deepseek-ai/dsh-commands' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import * as CommandFeedback from '@deepseek-ai/dsh-command-feedback' + +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 +}) + +/** Register one idle agent over a store-owned session, as an app's spine does. */ +function agent(ctx: Context): Agent { + const scope = ctx.plugin(() => {}) + const id = SessionId('feedback-loader-agent') + const session = ctx.sessions.create(id) + let status: AgentStatus = 'idle' + const value: Agent = { + id, + options: {}, + session, + ctx: scope.ctx, + get status() { return status }, + get acceptsNextStep() { return status === 'running' }, + send: () => {}, + followup: () => {}, + steer: () => {}, + inject: () => {}, + cancel() { status = 'idle' }, + whenIdle: () => Promise.resolve(), + } + ctx.agents.register(value) + return value +} + +describe('/feedback real Loader composition through cordis.yml', () => { + it('boots cordis.yml and records feedback without model-visible output', async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-command-feedback-loader-')) + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [ + "- name: '@deepseek-ai/dsh-agent'", + "- name: '@deepseek-ai/dsh-session'", + "- name: '@deepseek-ai/dsh-commands'", + "- name: '@deepseek-ai/dsh-command-feedback'", + '', + ].join('\n')) + + context = new Context() + context.baseUrl = pathToFileURL(root).href + '/' + await context.plugin(Loader) + context.loader.builtins.include = Include + const modules = new Map([ + ['@deepseek-ai/dsh-agent', AgentRegistry], + ['@deepseek-ai/dsh-session', SessionStore], + ['@deepseek-ai/dsh-commands', CommandService], + ['@deepseek-ai/dsh-command-feedback', CommandFeedback], + ]) + 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() + + const owner = agent(context) + const signal = new AbortController().signal + + // Discoverable through the composed registry, as a UI adapter finds it. + expect(context.commands.list(owner).map(command => command.name)).toContain('feedback') + + const accepted = await context.commands.execute(owner, '/feedback the diff view is unreadable', signal) + expect(accepted?.result).toEqual({ kind: 'success', text: 'Feedback recorded.' }) + const rejected = await context.commands.execute(owner, '/feedback', signal) + expect(rejected?.result).toEqual({ + kind: 'error', + text: 'Feedback text is required. Usage: /feedback ', + }) + + // The command records itself through the registry and does nothing else. + expect(owner.session.events.map(event => event.type)) + .toEqual(['command/run', 'command/done', 'command/run', 'command/done']) + const run = owner.session.events.find(event => event.type === 'command/run') + expect(run?.type === 'command/run' && run.data.args).toBe(' the diff view is unreadable') + + // Nothing reached the model. + expect(owner.session.deriveMessages()).toEqual([]) + expect(owner.session.surface.nodes).toEqual([]) + }) +}) diff --git a/packages/feedback/command-feedback/tsconfig.json b/packages/feedback/command-feedback/tsconfig.json new file mode 100644 index 0000000000..6a27b54d3a --- /dev/null +++ b/packages/feedback/command-feedback/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../ui/commands" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18cb1ec0d1..38e1c9eef5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2303,6 +2303,9 @@ importers: '@deepseek-ai/dsh-agent-spine-demo': specifier: workspace:^ version: link:../agent-spine-demo + '@deepseek-ai/dsh-command-feedback': + specifier: workspace:^ + version: link:../../feedback/command-feedback '@deepseek-ai/dsh-command-goal': specifier: workspace:^ version: link:../../goal/command-goal @@ -2358,6 +2361,33 @@ importers: specifier: ^3.17.0 version: 3.18.0 + packages/feedback/command-feedback: + devDependencies: + '@cordisjs/plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../../ui/commands + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + packages/fs/fs: devDependencies: '@deepseek-ai/dsh-brand': diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 42198d4eea..7a67ce8bac 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -7,5 +7,5 @@ "docs/testing.md": 1100, "examples/AGENTS.md": 310, "packages/AGENTS.md": 675, - "packages/README.md": 870 + "packages/README.md": 880 } diff --git a/tsconfig.base.json b/tsconfig.base.json index 00c19c4b8c..b87f867107 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -72,6 +72,7 @@ "./packages/compact/*/src/invariant.ts", "./packages/context/*/src/invariant.ts", "./packages/goal/*/src/invariant.ts", + "./packages/feedback/*/src/invariant.ts", "./packages/guard/*/src/invariant.ts", "./packages/plan/*/src/invariant.ts", "./packages/subagent/*/src/invariant.ts", @@ -161,6 +162,7 @@ "./packages/compact/*/src", "./packages/context/*/src", "./packages/goal/*/src", + "./packages/feedback/*/src", "./packages/guard/*/src", "./packages/plan/*/src", "./packages/subagent/*/src", diff --git a/tsconfig.host.json b/tsconfig.host.json index e2112b7f6a..5c1a7488c0 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -83,6 +83,7 @@ { "path": "./packages/goal/tool-goal" }, { "path": "./packages/goal/goal-session" }, { "path": "./packages/goal/command-goal" }, + { "path": "./packages/feedback/command-feedback" }, { "path": "./packages/context/time-context" }, { "path": "./packages/context/session-reference" }, { "path": "./packages/ui/user-interaction" }, From eb6fa864813f6513a037e79b4dbacf9d4477338d Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 21:33:53 +0800 Subject: [PATCH 002/104] fix(feedback): keep payload in feedback event --- .../2026-07-28-feedback-command.i18n.yaml | 4 +- .../feature/2026-07-28-feedback-command.md | 20 +++--- .../feature/2026-07-28-feedback-command.zh.md | 20 +++--- ...ssion-projection-and-command-log.i18n.yaml | 4 +- ...7-27-session-projection-and-command-log.md | 4 +- ...7-session-projection-and-command-log.zh.md | 4 +- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/commands.i18n.yaml | 6 +- docs/core-data-structures/commands.md | 6 ++ docs/core-data-structures/commands.zh.md | 6 ++ docs/event-producer-consumer.md | 2 +- docs/persistence-catalog.md | 23 +++++-- .../client/connection/src/client/fixture.ts | 12 ++-- .../src/client/sessions/conversation.ts | 5 +- .../src/client/sessions/fold-adapter.ts | 4 +- packages/client/runtime/tests/event-script.ts | 2 + .../client/runtime/tests/fold-adapter.spec.ts | 8 +++ .../src/client/chat/GenericCommandCard.tsx | 4 +- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/feedback/README.i18n.yaml | 4 +- packages/feedback/README.md | 4 +- packages/feedback/README.zh.md | 4 +- .../command-feedback/README.i18n.yaml | 4 +- packages/feedback/command-feedback/README.md | 17 +++-- .../feedback/command-feedback/README.zh.md | 17 +++-- .../feedback/command-feedback/package.json | 3 +- .../feedback/command-feedback/src/index.ts | 39 +++++++++--- .../command-feedback/src/invariant.ts | 4 +- .../tests/command-feedback.spec.ts | 62 +++++++++++-------- .../tests/loader-composition.spec.ts | 9 ++- .../feedback/command-feedback/tsconfig.json | 3 + packages/plan/plan-mode/README.i18n.yaml | 4 +- packages/plan/plan-mode/README.md | 2 +- packages/plan/plan-mode/README.zh.md | 2 +- packages/plan/plan-mode/src/index.ts | 1 + .../plan/plan-mode/tests/projection.spec.ts | 11 +++- packages/ui/commands/README.i18n.yaml | 4 +- packages/ui/commands/README.md | 4 +- packages/ui/commands/README.zh.md | 4 +- packages/ui/commands/src/index.ts | 17 ++++- packages/ui/commands/tests/commands.spec.ts | 19 ++++++ 42 files changed, 248 insertions(+), 130 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-28-feedback-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-feedback-command.i18n.yaml index ba56da8945..7a429953d8 100644 --- a/.agents/notes/implemented/feature/2026-07-28-feedback-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-feedback-command.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-feedback-command.md -2026-07-28-feedback-command.md: ae32d3908d568c4a511e8d9e2b8cf50569fb80bf -2026-07-28-feedback-command.zh.md: f69dbf6a50161e7f5048b76be46bc4063f9e757a +2026-07-28-feedback-command.md: 1c093d0e37eb72dc66e3c5569bd642557dde56a1 +2026-07-28-feedback-command.zh.md: 300946a71ac7485a4bc787dd70ae5357147627f3 diff --git a/.agents/notes/implemented/feature/2026-07-28-feedback-command.md b/.agents/notes/implemented/feature/2026-07-28-feedback-command.md index ae32d3908d..1c093d0e37 100644 --- a/.agents/notes/implemented/feature/2026-07-28-feedback-command.md +++ b/.agents/notes/implemented/feature/2026-07-28-feedback-command.md @@ -14,15 +14,15 @@ The capture surface has to be usable at the moment of annoyance, which rules out `@deepseek-ai/dsh-command-feedback` in `packages/feedback/command-feedback/` registers one global `feedback` command over `ctx.commands`. `/feedback ` acknowledges; bare or whitespace-only input returns a direct usage error. The handler is synchronous, injects only `commands`, and has no configuration. -The plugin appends **no session event of its own**. `dsh-commands` already writes a `command/run` / `command/done` pair for every dispatched command, carrying the command name, the verbatim unparsed suffix, the invocation source, and the settled outcome. Those records are log-only and non-surface, so the feedback lands in the session log and stays invisible to the model without this package contributing anything to the log format. The appends start persistence's ordinary eager drain; nothing forces a flush, so the acknowledgement reports that the entry is recorded in the log rather than already on disk. +The package declares the log-only `feedback/record { text }` session event and exports `recordFeedback(session, text)` as its command-independent producer. The producer discards surrounding whitespace, rejects an empty result, and appends exactly one event. `/feedback` delegates to it, so another UI, hook, or host integration can record the same domain fact without constructing a slash command. -Capture is deliberately inert: nothing in this repository reads those records back. +`dsh-commands` still writes its `command/run` / `command/done` lifecycle pair around `/feedback`, but this command sets `recordInput: false`. Its `command/run` therefore carries the command identity and source without `args`; the feedback text exists only in `feedback/record`, while `command/done` carries the acknowledgement outcome. All three records are log-only and non-surface. Their appends start persistence's ordinary eager drain; nothing forces a flush, so acknowledgement reports that the feedback is in the log rather than already on disk. -### Why no dedicated `session/feedback` event +Capture is deliberately inert: nothing in this repository reads `feedback/record`. -An earlier iteration declared one. It was removed because it duplicated a record the registry already writes: both would carry the same text, appended microseconds apart, and a consumer would have to decide which is authoritative. Selecting `command/run` records by command name is enough to find feedback, and it keeps this package free of the session event format entirely — no `SessionEventMap` merge, no invariant relation, no persistence catalog entry. +### Why feedback owns an event -The cost is that the recorded text is the raw suffix including its leading separator whitespace, and that feedback is distinguished from other commands only by name. Both are read-time concerns for a consumer that does not yet exist; neither justifies a second durable record now. +Feedback is a domain fact, while `/feedback` is one trigger. Keeping the only payload in `feedback/record` lets later triggers use the same event and lets consumers select feedback without depending on command names or parsing command lifecycle records. Omitting `command/run.args` for this definition avoids two authoritative-looking copies of one human remark. ### Why the model never sees it @@ -30,7 +30,7 @@ Feedback is about the session, not input to it. Injecting it as a user message w ### Verbatim text -Nothing is parsed. `/feedback /plan felt slow` records that literal text; the leading `/plan` is content, not a nested command. The handler trims only to decide whether any text was supplied. Control-word grammar of the kind `/goal` uses would make the corresponding literal feedback impossible to express, which is the opposite of what a capture surface is for. +Surrounding whitespace is discarded, but nothing else is parsed. `/feedback /plan felt slow` records `/plan felt slow`; the leading `/plan` is content, not a nested command. Control-word grammar of the kind `/goal` uses would make the corresponding literal feedback impossible to express, which is the opposite of what a capture surface is for. ### A new group @@ -38,7 +38,9 @@ Nothing is parsed. `/feedback /plan felt slow` records that literal text; the le ## Alternatives considered -**Declare a dedicated `session/feedback` log-only event.** Implemented first, then removed. It gave feedback a first-class queryable type with pre-trimmed text, but duplicated the registry's record, added a `SessionEventMap` member and persistence-catalog entry to the frozen log format, and created two records of one act with no rule for which wins. +**Use `command/run` as the feedback record.** Rejected because feedback would then be coupled to one trigger and consumers would have to identify a domain fact by command name. A non-command producer could not create the same record without pretending to execute a command. + +**Store the text in both `feedback/record` and `command/run.args`.** Rejected because one act would have two payload copies with no useful distinction. `recordInput: false` preserves the generic lifecycle while leaving the domain event authoritative. **Inject feedback as a user message via `agent.inject()`.** Needs no new event type and reuses the path `/goal` mutations take. Rejected: it makes the feedback model-visible, so it enters the next request, changes the run being commented on, and consumes tokens — contradicting all three parts of the no-perturbation requirement. @@ -54,8 +56,8 @@ Nothing is parsed. `/feedback /plan felt slow` records that literal text; the le The TUI mounts the command unconditionally — no configuration, no dependency on the goal stack. The headless CLI, ACP, and JSON-RPC apps do not consume `ctx.commands`, so `/feedback` is unavailable there. -This package is now small enough that its whole contract is the command definition plus one validation branch. It owns no session event, so it needs no invariant relation and cannot affect replay, forking, or crash recovery. +The package owns one independent append-only event with no cross-event or mutable-data relation for an invariant companion to check. The event follows the session log's existing replay, fork, persistence, and crash-tail behavior. -Deferred: no consumer; no structured fields; no amend or withdraw, since the log is append-only and this package adds no tombstone; the recorded text is untrimmed, so a consumer trims at read time; and no explicit durability barrier, so an entry recorded immediately before a crash can be lost with any other unflushed tail. +Deferred: no consumer; no structured fields; no amend or withdraw, since the log is append-only and this package adds no tombstone; and no explicit durability barrier, so an entry recorded immediately before a crash can be lost with any other unflushed tail. No snapshot accompanies this change. AGENTS.md asks for a keyless snapshot through a runnable example for product-user-visible behavior; this was skipped at the requester's explicit direction. The package tests plus a real Loader composition test over a `cordis.yml` are the whole of the evidence, alongside interactive verification in the assembled TUI. diff --git a/.agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md b/.agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md index f69dbf6a50..300946a71a 100644 --- a/.agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md @@ -14,15 +14,15 @@ Status: implemented 位于 `packages/feedback/command-feedback/` 的 `@deepseek-ai/dsh-command-feedback` 通过 `ctx.commands` 注册一个全局 `feedback` 命令。`/feedback ` 给出确认;空输入或仅含空白的输入返回直接用法错误。处理器是同步的,只注入 `commands`,且没有任何配置。 -该插件**不追加属于自己的会话事件**。`dsh-commands` 已经为每个已分发命令写入一对 `command/run` / `command/done`,携带命令名、原样未解析的后缀、调用来源以及结算结果。这些记录仅写入日志且非 surface,因此反馈会进入会话日志并对模型保持不可见,而本包无需向日志格式贡献任何内容。这些追加会启动持久化的常规即时排空;没有任何环节强制 flush,因此确认文本报告的是条目已记录在日志中,而非已经落盘。 +本包(package)声明仅写入日志的 `feedback/record { text }` 会话事件,并导出 `recordFeedback(session, text)`,作为不依赖命令的生产方。该生产方丢弃前后空白,拒绝空结果,并且恰好追加一个事件。`/feedback` 委托给它,因此其他 UI、钩子或 host 集成无需构造斜杠命令也能记录同一个领域事实。 -采集刻意不产生后续动作:本仓库中没有任何代码读回这些记录。 +`dsh-commands` 仍会围绕 `/feedback` 写入 `command/run` / `command/done` 生命周期配对,但该命令设置了 `recordInput: false`。因此,它的 `command/run` 携带命令标识与来源,但不携带 `args`;反馈文本只存在于 `feedback/record` 中,而 `command/done` 携带确认结果。三个记录都仅写入日志且非 surface。它们的追加会启动持久化的常规即时排空;没有任何环节强制 flush,因此确认文本报告的是反馈已进入日志,而非已经落盘。 -### 为何不设专用的 `session/feedback` 事件 +采集刻意不产生后续动作:本仓库中没有任何代码读取 `feedback/record`。 -早先的实现声明过该事件,后来将其移除,因为它重复了注册表已经写入的记录:两者会携带相同文本、相隔极短时间先后追加,而消费方还得判断以哪一条为准。依据命令名筛选 `command/run` 记录已足以找到反馈,同时让本包完全不涉及会话事件格式——没有 `SessionEventMap` 合并、没有不变式关系、没有持久化目录条目。 +### 为何反馈拥有自己的事件 -代价是被记录的文本为原始后缀,包含其前导分隔空白;且反馈仅凭命令名与其他命令相区分。两者都属于尚不存在的消费方在读取时需要处理的问题,目前都不足以支撑再增加一条持久记录。 +反馈是领域事实,而 `/feedback` 是一种触发方式。只把载荷保存在 `feedback/record` 中,既让后续触发方式可以使用同一个事件,也让消费方无需依赖命令名或解析命令生命周期记录即可筛选反馈。在该定义中省略 `command/run.args`,可避免同一条人类评价出现两个看起来都具有权威性的副本。 ### 为何模型永不看到它 @@ -30,7 +30,7 @@ Status: implemented ### 原样文本 -不做任何解析。`/feedback /plan felt slow` 记录的就是该字面文本;开头的 `/plan` 是内容,而非嵌套命令。处理器仅为判断是否提供了文本而修剪。若采用 `/goal` 那样的控制词语法,对应的字面反馈将无法表达,这与采集接口的目的正好相反。 +前后空白会被丢弃,但除此之外不做解析。`/feedback /plan felt slow` 记录 `/plan felt slow`;开头的 `/plan` 是内容,而非嵌套命令。若采用 `/goal` 那样的控制词语法,对应的字面反馈将无法表达,这与采集接口的目的正好相反。 ### 一个新的分组 @@ -38,7 +38,9 @@ Status: implemented ## 考虑过的替代方案 -**声明专用的 `session/feedback` 仅日志事件。** 先实现后移除。它让反馈拥有一等的可查询类型和预先修剪的文本,但重复了注册表的记录,向已冻结的日志格式新增了一个 `SessionEventMap` 成员与持久化目录条目,并使同一行为产生两条记录而没有取舍规则。 +**使用 `command/run` 作为反馈记录。** 已否决,因为这会将反馈与一种触发方式耦合,消费方还必须通过命令名识别领域事实。非命令生产方若不伪装成执行命令,就无法创建相同记录。 + +**同时在 `feedback/record` 与 `command/run.args` 中存储文本。** 已否决,因为同一行为会产生两个没有实质区别的载荷副本。`recordInput: false` 保留通用生命周期,同时让领域事件保持权威性。 **通过 `agent.inject()` 将反馈作为 user 消息注入。** 无需新增事件类型,并复用 `/goal` 变更所走的路径。已否决:它会让反馈对模型可见,从而进入下一次请求、改变正被评论的那次运行并消耗 token——与「不得扰动」要求的三个方面全部冲突。 @@ -54,8 +56,8 @@ Status: implemented TUI 无条件挂载该命令:没有配置,也不依赖 goal 栈。无头 CLI、ACP 和 JSON-RPC 应用不消费 `ctx.commands`,因此 `/feedback` 在那里不可用。 -本包现已小到其全部契约就是命令定义加一个校验分支。它不拥有任何会话事件,因此无需不变式关系,也不可能影响回放、fork 或崩溃恢复。 +本包拥有一个独立的仅追加事件,不存在跨事件关系或可变数据关系可供不变式伴生插件检查。该事件遵循会话日志现有的回放、fork、持久化和崩溃尾部行为。 -延期事项:没有消费方;没有结构化字段;不支持修改或撤回,因为日志仅追加且本包不新增 tombstone;被记录的文本未修剪,需由消费方在读取时处理;且没有显式持久化屏障,因此紧临崩溃前记录的条目可能与其他未 flush 的尾部一同丢失。 +延期事项:没有消费方;没有结构化字段;不支持修改或撤回,因为日志仅追加且本包不新增 tombstone;且没有显式持久化屏障,因此紧临崩溃前记录的条目可能与其他未 flush 的尾部一同丢失。 本次变更不附带 snapshot。AGENTS.md 要求面向产品用户的可见行为变更通过可运行示例附带无密钥 snapshot;此项按请求者的明确指示跳过。包测试连同一个基于真实 `cordis.yml` 的 Loader 组合测试即为全部证据,此外还有在组装后 TUI 中的交互验证。 diff --git a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml index 8f720e33b5..49c96e63a3 100644 --- a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md -2026-07-27-session-projection-and-command-log.md: 6a073c956c27bbfc65cff2d4f44ca12023df0cd5 -2026-07-27-session-projection-and-command-log.zh.md: 500f07968db049e4a174ff3b7a075bfe095283db +2026-07-27-session-projection-and-command-log.md: 6ffdae7df9e908356c972f077d66b831f3b6a1ff +2026-07-27-session-projection-and-command-log.zh.md: d2a40fb72245df413c7790932a41ddea3be7902d diff --git a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md index 6a073c956c..6ffdae7df9 100644 --- a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md +++ b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md @@ -115,11 +115,11 @@ The one existing violation of "no hooks through inject" — `DetailsInjected.use Two log-only (non-surface, model-invisible) events, mirroring the `tool/call`/`tool/result` pairing: ```ts ignore-check -'command/run': { commandId: string; name: string; args: string; source: CommandSource } +'command/run': { commandId: string; name: string; args?: string; source: CommandSource } 'command/done': { commandId: string; kind: 'success' | 'error'; text?: string } ``` -The host command executor (`packages/ui/commands`) appends `command/run` before invoking the handler and `command/done` at settlement — direct standalone appends on the receiving agent's session, in the same shape as every other plugin-owned log-only event after the [synthetic-turn removal](../../implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md): no turn wraps them (turns describe model-loop executions only), persistence drains them at ordinary checkpoints, and the commands package's own invariant companion enforces the run/done pairing. The payload is structured — `name` and `args` are the parser's own split (`parseCommand`'s name and rawInput), so a consumer (a projection unit folding its own command records, a rich command card) never re-parses a line. `text` is the handler's verbatim outcome — factual data of the same nature as `tool/result.content`, not presentation (how it is laid out remains client-computed at render time, satisfying the "presentation never enters the log" red line). Domains that want the model to know the outcome keep doing what they do today (plan's narration, goal's inject) — that is a domain decision, unchanged. +The host command executor (`packages/ui/commands`) appends `command/run` before invoking the handler and `command/done` at settlement — direct standalone appends on the receiving agent's session, in the same shape as every other plugin-owned log-only event after the [synthetic-turn removal](../../implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md): no turn wraps them (turns describe model-loop executions only), persistence drains them at ordinary checkpoints, and the commands package's own invariant companion enforces the run/done pairing. The payload is structured — `name` and, by default, `args` are the parser's own split (`parseCommand`'s name and rawInput), so a consumer (a projection unit folding its own command records, a rich command card) never re-parses a line. A definition sets `recordInput: false` when its authoritative domain event owns the payload; `command/run` then omits `args` rather than duplicating it. `text` is the handler's verbatim outcome — factual data of the same nature as `tool/result.content`, not presentation (how it is laid out remains client-computed at render time, satisfying the "presentation never enters the log" red line). Domains that want the model to know the outcome keep doing what they do today (plan's narration, goal's inject) — that is a domain decision, unchanged. Because committed events broadcast on the mux stream, refresh persistence, multi-tab sync, and fork/resume recovery all come for free. The `command.execute` RPC degrades to admission — `{ matched, commandId? }`: whether the line resolved, and the minted pairing id when it did, so the issuing client can correlate its request with the flow node the lifecycle events produce. The one-shot notice channel (`runDetached` → `noticeFor`) is retired. diff --git a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md index 500f07968d..d2a40fb722 100644 --- a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md @@ -115,11 +115,11 @@ type UseProjection = { 两个仅日志(非 surface、模型不可见)事件,镜像 `tool/call`/`tool/result` 的配对: ```ts ignore-check -'command/run': { commandId: string; name: string; args: string; source: CommandSource } +'command/run': { commandId: string; name: string; args?: string; source: CommandSource } 'command/done': { commandId: string; kind: 'success' | 'error'; text?: string } ``` -host 侧命令执行器(`packages/ui/commands`)在调用处理器前追加 `command/run`,在结算时追加 `command/done`——在接收 agent 的会话上直接独立追加,与[合成轮次移除](../../implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md)之后所有插件自有 log-only 事件同一形状:没有轮次包裹它们(轮次只描述模型循环执行),持久化在常规检查点排空它们,run/done 配对由 commands 包自己的 invariant 伴生插件把守。载荷是结构化的——`name` 与 `args` 就是解析器自己的切分(`parseCommand` 的 name 与 rawInput),因此消费方(折叠自己命令记录的投影单元、富命令卡片)永远无需重新解析行文本。`text` 是处理器的原样结果——与 `tool/result.content` 同一性质的事实数据,不是呈现(版式如何编排仍由客户端在渲染时计算,满足「呈现永不入日志」这条红线)。想让模型知道结果的领域继续做它们今天在做的事(plan 的旁白、goal 的注入)——那是领域自己的决定,保持不变。 +host 侧命令执行器(`packages/ui/commands`)在调用处理器前追加 `command/run`,在结算时追加 `command/done`——在接收 agent 的会话上直接独立追加,与[合成轮次移除](../../implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md)之后所有插件自有 log-only 事件同一形状:没有轮次包裹它们(轮次只描述模型循环执行),持久化在常规检查点排空它们,run/done 配对由 commands 包自己的 invariant 伴生插件把守。载荷是结构化的——`name` 以及默认携带的 `args` 来自解析器自己的切分(`parseCommand` 的 name 与 rawInput),因此消费方(折叠自己命令记录的投影单元、富命令卡片)永远无需重新解析行文本。当载荷由权威领域事件持有时,命令定义会设置 `recordInput: false`;此时 `command/run` 省略 `args`,而不是重复该载荷。`text` 是处理器的原样结果——与 `tool/result.content` 同一性质的事实数据,不是呈现(版式如何编排仍由客户端在渲染时计算,满足「呈现永不入日志」这条红线)。想让模型知道结果的领域继续做它们今天在做的事(plan 的旁白、goal 的注入)——那是领域自己的决定,保持不变。 由于已提交事件会在 mux 流上广播,刷新后仍在、多标签页同步、fork/恢复后可还原这三件事随之全部自动获得。`command.execute` RPC 退化为准入判定——`{ matched, commandId? }`:该行是否匹配命中,以及命中时新铸的配对 id,发起命令的客户端据此把自己的请求与生命周期事件产出的 flow 节点关联起来。一次性通知通道(`runDetached` → `noticeFor`)就此下线。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 967241fbf6..54291934fd 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -420,7 +420,7 @@ A command was registered or unregistered. This is an unfiltered registry notific 'commands/change'(): void ``` -Source: [`packages/ui/commands/src/index.ts:154`](../../packages/ui/commands/src/index.ts) +Source: [`packages/ui/commands/src/index.ts:161`](../../packages/ui/commands/src/index.ts) ## `domain/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index e0dd17fa02..ffd80cc6e5 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -442,7 +442,7 @@ async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise CommandResult | Promise } diff --git a/docs/core-data-structures/commands.zh.md b/docs/core-data-structures/commands.zh.md index 1a51305df3..f90e7c93c4 100644 --- a/docs/core-data-structures/commands.zh.md +++ b/docs/core-data-structures/commands.zh.md @@ -31,6 +31,12 @@ interface CommandDefinition { readonly description: string /** Optional free-form input hint advertised to capable clients. */ readonly input?: CommandInputDescriptor + /** + * Whether `command/run` records `rawInput`. Defaults to true. A command + * whose domain event owns the payload sets this false to avoid duplicating + * that payload in the session log. + */ + readonly recordInput?: boolean /** Execute against the receiving agent without sending the command to the model. */ readonly handler: (invocation: CommandInvocation) => CommandResult | Promise } diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 9d66b52bfb..a169747843 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -24,7 +24,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/step` | `serial` | [`packages/core/agent/src/types.ts:326`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`time-context`](../packages/context/time-context), [`tool-skill`](../packages/skill/tool-skill), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:373`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp) | -| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | +| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:161`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 7c027afd68..7c4dd1010b 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -185,7 +185,7 @@ Source: [`packages/core/session/src/types.ts:219`](../packages/core/session/src/ 'command/done': { commandId: CommandId; kind: 'success' | 'error'; text?: string } ``` -Source: [`packages/ui/commands/src/index.ts:138`](../packages/ui/commands/src/index.ts) +Source: [`packages/ui/commands/src/index.ts:145`](../packages/ui/commands/src/index.ts) #### `command/run` — log-only @@ -197,12 +197,13 @@ Source: [`packages/ui/commands/src/index.ts:138`](../packages/ui/commands/src/in * and `args` are `parseCommand`'s own split (name and verbatim rawInput, * separator whitespace included), so a consumer (a projection unit * folding its own command records, a rich command card) never re-parses - * a line. + * a line. `args` is absent when the definition sets `recordInput: false` + * because an authoritative domain event owns the input payload. */ -'command/run': { commandId: CommandId; name: string; args: string; source: CommandSource } +'command/run': { commandId: CommandId; name: string; args?: string; source: CommandSource } ``` -Source: [`packages/ui/commands/src/index.ts:132`](../packages/ui/commands/src/index.ts) +Source: [`packages/ui/commands/src/index.ts:139`](../packages/ui/commands/src/index.ts) ### `compact/*` @@ -256,6 +257,20 @@ Types: [ContentBlock](core-data-structures/core.md) Source: [`packages/compact/compact/src/types.ts:22`](../packages/compact/compact/src/types.ts) +### `feedback/*` + +#### `feedback/record` — log-only + +```ts persistence-catalog +/** + * One recorded human remark about this session. Log-only and independent + * of its trigger; it never enters the model surface or derived history. + */ +'feedback/record': { text: string } +``` + +Source: [`packages/feedback/command-feedback/src/index.ts:24`](../packages/feedback/command-feedback/src/index.ts) + ### `hook/*` #### `hook/invoked` — log-only diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index be9ba79347..5d5e34bf31 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -304,9 +304,9 @@ function viewFor(event: SessionEvent, log: readonly SessionEvent[]): ToolEventVi /** * Fixture parallel of the plan unit's double-event fold: `command/run` - * records named `plan` set the wanted target (`off` → false, else true); - * `plan/mode` commits and clears it. `wanted` is exposed for the prompt - * boundary (the fixture's agent/step parallel). + * records named `plan` with recorded input set the wanted target (`off` → + * false, else true); `plan/mode` commits and clears it. `wanted` is exposed + * for the prompt boundary (the fixture's agent/step parallel). */ function foldPlan(log: readonly SessionEvent[]): { active: boolean; pending: boolean; wanted: boolean | null } { let active = false @@ -315,7 +315,8 @@ function foldPlan(log: readonly SessionEvent[]): { active: boolean; pending: boo const item = event as unknown as { type: string; data?: Record } if (item.type === 'command/run' && item.data?.['name'] === 'plan') { const args = item.data['args'] - wanted = (typeof args === 'string' ? args : '').trim() !== 'off' + if (typeof args !== 'string') continue + wanted = args.trim() !== 'off' } else if (item.type === 'plan/mode') { active = item.data?.['active'] === true wanted = null @@ -374,8 +375,9 @@ function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event: }] } // The plan unit advances on its two folded event kinds. + const commandData = event as unknown as { data: { name?: string; args?: unknown } } if (type === 'plan/mode' || (type === 'command/run' - && (event as unknown as { data: { name?: string } }).data.name === 'plan')) { + && commandData.data.name === 'plan' && typeof commandData.data.args === 'string')) { return [{ type: 'session/projection', sessionId: id, diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index f5f0717236..474f7cc8b3 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -140,7 +140,10 @@ export interface CommandNode { commandId: CommandId /** Command name (run payload's structured field); null when the run fell outside the window. */ name: string | null - /** Verbatim rawInput after the name, separator whitespace included (run payload); null when the run fell outside the window. */ + /** + * Verbatim rawInput after the name, including separator whitespace; null + * when omitted by the command or when the run fell outside the window. + */ args: string | null /** Settlement outcome (done payload); null while the command is still executing. */ outcome: { kind: 'success' | 'error'; text?: string } | null diff --git a/packages/client/runtime/src/client/sessions/fold-adapter.ts b/packages/client/runtime/src/client/sessions/fold-adapter.ts index 039c36056b..c884d2ec8e 100644 --- a/packages/client/runtime/src/client/sessions/fold-adapter.ts +++ b/packages/client/runtime/src/client/sessions/fold-adapter.ts @@ -234,10 +234,10 @@ export class FoldAdapter { // enter the client program, so this wire consumer narrows structurally // (the same posture as tool/code-dispatch in session.ts). if ((event.type as string) === 'command/run') { - const data = event.data as unknown as { commandId: CommandId; name: string; args: string } + const data = event.data as unknown as { commandId: CommandId; name: string; args?: string } this.commandIdx.set(data.commandId, { kind: 'command', seq: event.seq, time: event.time, - commandId: data.commandId, name: data.name, args: data.args, outcome: null, + commandId: data.commandId, name: data.name, args: data.args ?? null, outcome: null, }) return } diff --git a/packages/client/runtime/tests/event-script.ts b/packages/client/runtime/tests/event-script.ts index 53f80e0e69..7da9bf0184 100644 --- a/packages/client/runtime/tests/event-script.ts +++ b/packages/client/runtime/tests/event-script.ts @@ -67,6 +67,8 @@ export const ev = { at(seq, { type: 'turn/end', data: { turn, reason: { kind: reason } } }), commandRun: (seq: number, commandId: string, name: string, args = ''): SessionEvent => at(seq, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }), + commandRunWithoutInput: (seq: number, commandId: string, name: string): SessionEvent => + at(seq, { type: 'command/run', data: { commandId, name, source: { kind: 'user' } } }), commandDone: (seq: number, commandId: string, kind: 'success' | 'error' = 'success', text?: string): SessionEvent => at(seq, { type: 'command/done', data: { commandId, kind, ...text === undefined ? {} : { text } } }), } diff --git a/packages/client/runtime/tests/fold-adapter.spec.ts b/packages/client/runtime/tests/fold-adapter.spec.ts index b40bdb4111..0b14fda5c3 100644 --- a/packages/client/runtime/tests/fold-adapter.spec.ts +++ b/packages/client/runtime/tests/fold-adapter.spec.ts @@ -195,6 +195,14 @@ describe('FoldAdapter', () => { }) }) + it('represents command input omitted by the host as null', () => { + const adapter = new FoldAdapter() + adapter.reset([ev.commandRunWithoutInput(0, 'cmd-private', 'feedback')], 0) + expect(adapter.nodes().nodes[0]).toMatchObject({ + kind: 'command', name: 'feedback', args: null, outcome: null, + }) + }) + it('soft-falls a done-only window into a node built from the done (cross-window cut)', () => { const adapter = new FoldAdapter() adapter.reset([ev.commandDone(80, 'cmd-3', 'error', '失败了')], 80) diff --git a/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx index 1dfea5488b..1d6db2581d 100644 --- a/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx @@ -21,8 +21,8 @@ export function GenericCommandCard({ node }: CommandRowOwnerProps) { ? '执行中…' : text ?? (node.outcome.kind === 'error' ? '命令失败' : '已完成') // Display line rebuilt from the structured payload (args carries its own - // separator whitespace verbatim); a cross-window node whose run page fell - // out of the window has neither. + // separator whitespace verbatim); omitted input and a cross-window node + // whose run page fell out both render without it. const title = node.name === null ? '命令' : `/${node.name}${node.args ?? ''}` return ( CommandResult | Promise;\n}', + declaration: 'export interface CommandDefinition {\n readonly name: string;\n readonly description: string;\n readonly input?: CommandInputDescriptor;\n readonly recordInput?: boolean;\n readonly handler: (invocation: CommandInvocation) => CommandResult | Promise;\n}', }, { name: 'CommandDescriptor', diff --git a/packages/feedback/README.i18n.yaml b/packages/feedback/README.i18n.yaml index eca3b1c420..31ed2d25e8 100644 --- a/packages/feedback/README.i18n.yaml +++ b/packages/feedback/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/feedback/README.md -README.md: ab7bc6f3e3a3be0c280855ff80e92c7d7a7e665e -README.zh.md: 9c050ac42aa468895c04124a76a3bce58756df0e +README.md: 7962a16ee9bc7d8a969a466591d761829cd55d7f +README.zh.md: aad8f4d797ff16a5ef9be4c968fb28d708bad13e diff --git a/packages/feedback/README.md b/packages/feedback/README.md index ab7bc6f3e3..7962a16ee9 100644 --- a/packages/feedback/README.md +++ b/packages/feedback/README.md @@ -6,6 +6,6 @@ The feedback family lets a human record a remark about the session without actin | Package | Role | ctx key | |---|---|---| -| `command-feedback/` | Human-facing `/feedback` command recorded through the command plane | — | +| `command-feedback/` | Trigger-independent `feedback/record` event plus the human-facing `/feedback` producer | — | -A recorded remark is log-only: it never enters the model surface or derived history, and no shipped plugin consumes it. A future consumer reads the command records from the session log rather than changing how they are captured. +A recorded remark is log-only: it never enters the model surface or derived history, and no shipped plugin consumes it. A future consumer reads `feedback/record` events from the session log rather than changing how they are captured. diff --git a/packages/feedback/README.zh.md b/packages/feedback/README.zh.md index 9c050ac42a..aad8f4d797 100644 --- a/packages/feedback/README.zh.md +++ b/packages/feedback/README.zh.md @@ -6,6 +6,6 @@ feedback 家族让人类记录对会话的评价,但不据此采取任何动 | 包 | 职责 | ctx 键 | |---|---|---| -| `command-feedback/` | 面向用户的 `/feedback` 命令,通过命令平面完成记录 | 无 | +| `command-feedback/` | 与触发方式无关的 `feedback/record` 事件,以及面向用户的 `/feedback` 生产方 | 无 | -被记录的评价仅写入日志:它绝不会进入模型 surface 或派生历史,随附插件也不会消费它。未来的消费方从会话日志中读取命令记录,而不是改变它们的采集方式。 +被记录的评价仅写入日志:它绝不会进入模型 surface 或派生历史,随附插件也不会消费它。未来的消费方从会话日志中读取 `feedback/record` 事件,而不是改变它们的采集方式。 diff --git a/packages/feedback/command-feedback/README.i18n.yaml b/packages/feedback/command-feedback/README.i18n.yaml index 37f10ac485..47c169ec3f 100644 --- a/packages/feedback/command-feedback/README.i18n.yaml +++ b/packages/feedback/command-feedback/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/feedback/command-feedback/README.md -README.md: 90992b7295536a9099766910f616e640d4b4bcfe -README.zh.md: a7c4f03997cea182ed24dcfc7f309dc3bd872d5e +README.md: c9650d6a2c595550545b3dbf07f62e6aa65f39b9 +README.zh.md: ba24276ba1bd71a4eb68c7fdb48a3760bdbec8fc diff --git a/packages/feedback/command-feedback/README.md b/packages/feedback/command-feedback/README.md index 90992b7295..c9650d6a2c 100644 --- a/packages/feedback/command-feedback/README.md +++ b/packages/feedback/command-feedback/README.md @@ -2,24 +2,24 @@ English | [中文](README.zh.md) -Human-facing `/feedback` capture. The plugin registers one global command through [`ctx.commands`](../../ui/commands/README.md), so every composed command adapter discovers it; the shipped TUI executes it without a model turn. +Trigger-independent session feedback plus human-facing `/feedback` capture. The package exports `recordFeedback(session, text)`, which appends one log-only `feedback/record` event. Its plugin registers one global command through [`ctx.commands`](../../ui/commands/README.md), so every composed command adapter discovers it; the shipped TUI executes it without a model turn. ## Command contract | Input | Result | |---|---| -| `/feedback ` | Acknowledge with `Feedback recorded.` The registry's `command/run` record carries the verbatim text. | +| `/feedback ` | Append `feedback/record` and acknowledge with `Feedback recorded.` | | `/feedback` | Return a direct usage error. Whitespace-only input is treated as empty. | -Feedback text is never parsed: no truncation, case folding, or control words. Text that looks like another command, such as `/feedback /plan felt slow`, is feedback content. Repeated commands each produce their own record; nothing is replaced or merged. +Surrounding whitespace is discarded, but feedback is otherwise unparsed: no truncation, case folding, or control words. Text that looks like another command, such as `/feedback /plan felt slow`, is feedback content. Repeated commands each produce their own event; nothing is replaced or merged. ## What this plugin does and does not do -The command records a remark and does nothing else. It appends no session event of its own, starts no model work, and no plugin in this repository reads its records. +`recordFeedback(session, text)` is the command-independent write path. It rejects empty normalized text and appends `feedback/record { text }`; a different UI, hook, or host integration can call it without constructing a slash command. The `/feedback` handler uses that producer, starts no model work, and no plugin in this repository reads the event. -The record is the command registry's own `command/run` / `command/done` pairing, which [`dsh-commands`](../../ui/commands/README.md) appends for every dispatched command. Those appends start persistence's ordinary eager drain; neither the registry nor this command forces a `session/flush`, so the acknowledgement means the entry is in the log, not that it has already reached disk. `command/run` carries the command name, the verbatim unparsed suffix, and the invocation source; the paired `command/done` carries the outcome. Both are log-only and are absent from the ordered surface, from `deriveMessages()`, and from every model request. A rejected empty input still leaves that pairing, settled as `kind: 'error'`, so no entry can be mistaken for accepted feedback. +The feedback text appears in exactly one durable payload: `feedback/record`. [`dsh-commands`](../../ui/commands/README.md) still appends its generic `command/run` / `command/done` pairing, but this definition sets `recordInput: false`, so `command/run` omits `args`; the paired `command/done` carries only the outcome. All three events are log-only and absent from the ordered surface, `deriveMessages()`, and model requests. These appends start persistence's ordinary eager drain, but neither producer forces `session/flush`, so acknowledgement means the feedback is in the log, not that it has reached disk. Rejected empty input leaves only the command pairing settled as `kind: 'error'`, with no `feedback/record`. -A dedicated `session/feedback` event was considered and rejected: it would duplicate a record the registry already writes, and a consumer can select feedback by the command name it already stores. +The event is authoritative rather than the command record because feedback may arrive through a trigger other than `/feedback`. Keeping the payload out of `command/run` avoids two records carrying the same text. ## Composition @@ -40,7 +40,7 @@ The TUI app mounts this command unconditionally; it has no configuration and no #### What the model sees -Nothing. The slash input, the recorded text, and the acknowledgement are all absent from model requests. The registry's `command/run` and `command/done` records are log-only and carry no `surfaceOp`, so they never reach the ordered surface, `deriveMessages()`, or a system prompt. Recording feedback during a turn does not change that turn's remaining requests. +Nothing. The slash input, `feedback/record`, and the acknowledgement are absent from model requests. The feedback event and registry lifecycle records are log-only and carry no `surfaceOp`, so they never reach the ordered surface, `deriveMessages()`, or a system prompt. Recording feedback during a turn does not change that turn's remaining requests. #### Token effect @@ -52,9 +52,8 @@ Independent of the model request path. Recording appends to the session log only ## Known Limitations and Deferred Work -- **Nothing consumes the recorded feedback** — capture is deliberately inert. There is no retrieval, aggregation, export, or reporting surface, and no model-facing tool reads it; a consumer is a separate package that selects `command/run` records by command name. +- **Nothing consumes the recorded feedback** — capture is deliberately inert. There is no retrieval, aggregation, export, or reporting surface, and no model-facing tool reads `feedback/record`; a consumer is a separate package. - **No structured fields** — an entry is one free-text string with no category, severity, or referenced-event link, so feedback cannot be filtered by subject without re-reading its text. - **No amend or withdraw** — the session log is append-only and this package adds no tombstone, so a mistaken entry stays recorded and can only be superseded by a later one. -- **Untrimmed text in the record** — the handler trims only to validate; `command/run` stores the raw suffix, including its leading separator whitespace, so a consumer trims at read time. - **No explicit durability barrier** — the acknowledgement follows the append, not a flush, so an entry recorded immediately before a crash can be lost with any other unflushed tail. Feedback is not worth forcing a synchronous disk write for; a consumer that needs one awaits `ctx.sessions.flush(session)`. - **TUI only in the shipped apps** — the headless CLI, ACP automation, and JSON-RPC adapters do not mount `ctx.commands`, so `/feedback` is unavailable there. diff --git a/packages/feedback/command-feedback/README.zh.md b/packages/feedback/command-feedback/README.zh.md index a7c4f03997..ba24276ba1 100644 --- a/packages/feedback/command-feedback/README.zh.md +++ b/packages/feedback/command-feedback/README.zh.md @@ -2,24 +2,24 @@ [English](README.md) | 中文 -面向用户的 `/feedback` 采集。该插件通过 [`ctx.commands`](../../ui/commands/README.md) 注册一个全局命令,因此每个已组合的命令适配器都能发现它;随附 TUI 无需模型轮次即可执行。 +与触发方式无关的会话反馈,以及面向用户的 `/feedback` 采集。本包(package)导出 `recordFeedback(session, text)`,后者追加一个仅写入日志的 `feedback/record` 事件。该插件通过 [`ctx.commands`](../../ui/commands/README.md) 注册一个全局命令,因此每个已组合的命令适配器都能发现它;随附 TUI 无需模型轮次即可执行。 ## 命令契约 | 输入 | 结果 | |---|---| -| `/feedback ` | 以 `Feedback recorded.` 确认。注册表的 `command/run` 记录携带原样文本。 | +| `/feedback ` | 追加 `feedback/record`,并以 `Feedback recorded.` 确认。 | | `/feedback` | 返回一个直接用法错误。仅含空白的输入视为空输入。 | -反馈文本从不被解析:没有截断、大小写折叠或控制词。看起来像另一个命令的文本(例如 `/feedback /plan felt slow`)就是反馈内容。重复执行命令会各自产生自己的记录,不会替换或合并。 +前后空白会被丢弃,但除此之外,反馈内容不会被解析:没有截断、大小写折叠或控制词。看起来像另一个命令的文本(例如 `/feedback /plan felt slow`)就是反馈内容。重复执行命令时,每次都会产生一个事件;不会发生替换或合并。 ## 本插件做什么、不做什么 -该命令记录一条评价,不做别的事。它不追加属于自己的会话事件,不启动任何模型工作,本仓库中也没有任何插件读取它的记录。 +`recordFeedback(session, text)` 是不依赖命令的写入路径。它拒绝规范化后为空的文本,并追加 `feedback/record { text }`;其他 UI、钩子或 host 集成无需构造斜杠命令即可调用它。`/feedback` 处理器通过该生产方写入,不启动任何模型工作;本仓库中也没有任何插件读取该事件。 -记录来自命令注册表自身的 `command/run` / `command/done` 配对,由 [`dsh-commands`](../../ui/commands/README.md) 为每个已分发命令追加。这些追加会启动持久化的常规即时排空;注册表与本命令都不会强制 `session/flush`,因此确认文本表示条目已进入日志,而不表示它已经落盘。`command/run` 携带命令名、原样未解析的后缀以及调用来源;配对的 `command/done` 携带结果。两者都仅写入日志,不出现在有序 surface、`deriveMessages()` 以及任何模型请求中。被拒绝的空输入仍会留下该配对,并以 `kind: 'error'` 结算,因此任何条目都不会被误认为已接受的反馈。 +反馈文本只出现在一个持久载荷中:`feedback/record`。[`dsh-commands`](../../ui/commands/README.md) 仍会追加通用的 `command/run` / `command/done` 配对,但此定义设置了 `recordInput: false`,因此 `command/run` 会省略 `args`;配对的 `command/done` 只携带结果。三个事件都仅写入日志,不出现在有序 surface、`deriveMessages()` 以及模型请求中。这些追加会启动持久化的常规即时排空,但两个生产方都不会强制 `session/flush`,因此确认文本表示反馈已进入日志,而不表示它已经落盘。被拒绝的空输入只会留下以 `kind: 'error'` 结算的命令配对,不会产生 `feedback/record`。 -曾考虑并否决了专用的 `session/feedback` 事件:它会重复注册表已经写入的记录,而消费方可以依据注册表已存储的命令名筛选反馈。 +权威记录是该事件,而不是命令记录,因为反馈可能来自 `/feedback` 之外的触发方式。让载荷不进入 `command/run`,可避免两条记录携带相同文本。 ## 组合 @@ -40,7 +40,7 @@ TUI 应用无条件挂载此命令;它没有配置,也不依赖持久 goal #### 模型看到的内容 -无。斜杠输入、被记录的文本以及确认文本都不出现在模型请求中。注册表的 `command/run` 与 `command/done` 记录仅写入日志且不携带 `surfaceOp`,因此它们绝不会进入有序 surface、`deriveMessages()` 或系统提示词。在某个轮次中记录反馈不会改变该轮次剩余的请求。 +无。斜杠输入、`feedback/record` 以及确认文本都不出现在模型请求中。反馈事件和注册表生命周期记录仅写入日志且不携带 `surfaceOp`,因此它们绝不会进入有序 surface、`deriveMessages()` 或系统提示词。在某个轮次中记录反馈不会改变该轮次剩余的请求。 #### Token 影响 @@ -52,9 +52,8 @@ TUI 应用无条件挂载此命令;它没有配置,也不依赖持久 goal ## 已知限制与暂缓工作 -- **没有任何消费方读取被记录的反馈**:采集刻意不产生任何后续动作。这里没有检索、聚合、导出或报告 surface,也没有面向模型的工具读取它;消费方是另一个依据命令名筛选 `command/run` 记录的独立包。 +- **没有任何消费方读取被记录的反馈**:采集刻意不产生任何后续动作。这里没有检索、聚合、导出或报告 surface,也没有面向模型的工具读取 `feedback/record`;消费方是另一个独立包。 - **没有结构化字段**:一条条目就是一个自由文本字符串,没有类别、严重程度或关联事件链接,因此无法在不重读文本的情况下按主题过滤反馈。 - **不支持修改或撤回**:会话日志是仅追加的,本包也不新增 tombstone,因此错误的条目会一直保留在记录中,只能由后续条目取代。 -- **记录中的文本未修剪**:处理器只为校验而修剪;`command/run` 存储原始后缀,包含其前导分隔空白,因此消费方需在读取时修剪。 - **没有显式持久化屏障**:确认文本紧随追加而非 flush,因此紧临崩溃前记录的条目可能与其他未 flush 的尾部一同丢失。为反馈强制同步写盘并不值得;需要该保证的消费方可自行等待 `ctx.sessions.flush(session)`。 - **随附应用中只有 TUI 使用此命令**:无头 CLI、ACP 自动化和 JSON-RPC 适配器不挂载 `ctx.commands`,因此 `/feedback` 在那里不可用。 diff --git a/packages/feedback/command-feedback/package.json b/packages/feedback/command-feedback/package.json index 6ad91d0e0d..25bc8446c3 100644 --- a/packages/feedback/command-feedback/package.json +++ b/packages/feedback/command-feedback/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-command-feedback", - "description": "Human-facing slash command that records session feedback as a log-only event", + "description": "Log-only session feedback producer and human-facing slash command", "version": "0.0.1", "private": true, "type": "module", @@ -29,6 +29,7 @@ "peerDependencies": { "@deepseek-ai/dsh-commands": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { diff --git a/packages/feedback/command-feedback/src/index.ts b/packages/feedback/command-feedback/src/index.ts index 7bf7cd0853..ae78b3cd4f 100644 --- a/packages/feedback/command-feedback/src/index.ts +++ b/packages/feedback/command-feedback/src/index.ts @@ -1,24 +1,45 @@ /** - * Human-facing `/feedback` command. It records a remark about the session and - * does nothing else: the command registry's own `command/run` and - * `command/done` events are the whole record, so this plugin only validates the - * input and acknowledges it. Those appends are eager but unflushed, so the - * acknowledgement reports the entry is logged, not that it reached disk. + * Session feedback event plus the human-facing `/feedback` producer. Recording + * appends one authoritative log-only event and does not start model work. The + * append is eager but unflushed, so acknowledgement reports that the entry is + * logged, not that it reached disk. * @module @deepseek-ai/dsh-command-feedback */ import type { Context } from 'cordis' import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands' +import type { Session } from '@deepseek-ai/dsh-session' export const name = 'command-feedback' export const inject = ['commands'] const USAGE = 'Usage: /feedback ' +declare module '@deepseek-ai/dsh-session' { + interface SessionEventMap { + /** + * One recorded human remark about this session. Log-only and independent + * of its trigger; it never enters the model surface or derived history. + */ + 'feedback/record': { text: string } + } +} + /** - * Validate and acknowledge one feedback entry. `command/run` already carries - * the verbatim text, so no further append is needed; returning an error instead - * settles that record as `kind: 'error'` and leaves no accepted feedback. + * Record feedback independently of any UI trigger. + * @param session - session the feedback describes. + * @param text - human-authored feedback; surrounding whitespace is discarded. + * @throws {TypeError} when the normalized text is empty. + */ +export function recordFeedback(session: Session, text: string): void { + const normalized = text.trim() + if (normalized.length === 0) throw new TypeError('feedback text must not be empty') + session.append('feedback/record', { text: normalized }) +} + +/** + * Validate, record, and acknowledge one feedback entry. Returning an error + * leaves no `feedback/record` event. * @param invocation - receiving agent, raw command input, and UI cancellation. * @returns an acknowledgement, or a usage error when no feedback text was supplied. */ @@ -26,6 +47,7 @@ function executeFeedbackCommand(invocation: CommandInvocation): CommandResult { if (invocation.rawInput.trim().length === 0) { return { kind: 'error', text: `Feedback text is required. ${USAGE}` } } + recordFeedback(invocation.agent.session, invocation.rawInput) return { kind: 'success', text: 'Feedback recorded.' } } @@ -35,6 +57,7 @@ export function apply(ctx: Context): void { name: 'feedback', description: 'record feedback about this session', input: { hint: '' }, + recordInput: false, handler: executeFeedbackCommand, }) } diff --git a/packages/feedback/command-feedback/src/invariant.ts b/packages/feedback/command-feedback/src/invariant.ts index 72a3ead213..9c825a6e87 100644 --- a/packages/feedback/command-feedback/src/invariant.ts +++ b/packages/feedback/command-feedback/src/invariant.ts @@ -15,8 +15,8 @@ export const name = 'command-feedback-invariant' export const inject = ['invariants'] /** - * No runtime invariant: this command declares no session event and owns no state projection. The - * `command/run`/`command/done` pairing that records feedback belongs to `dsh-commands`. + * No runtime invariant: each `feedback/record` is an independent append-only + * fact with no cross-event or mutable-data relationship. */ const install: InvariantInstaller = () => {} diff --git a/packages/feedback/command-feedback/tests/command-feedback.spec.ts b/packages/feedback/command-feedback/tests/command-feedback.spec.ts index 362bb8ce30..853e967176 100644 --- a/packages/feedback/command-feedback/tests/command-feedback.spec.ts +++ b/packages/feedback/command-feedback/tests/command-feedback.spec.ts @@ -58,15 +58,11 @@ async function run(test: Harness, suffix = ''): Promise<{ kind: string; text?: s return settled.result } -/** The registry's durable record of each accepted command, in log order. */ -function commandRecords(session: Session): { name: string; args: string; kind: string }[] { - const runs = session.events.filter(event => event.type === 'command/run') - return runs.map((event) => { - const done = session.events.find(item => - item.type === 'command/done' && item.data.commandId === event.data.commandId) - if (done?.type !== 'command/done') throw new Error('every command/run must be paired') - return { name: event.data.name, args: event.data.args, kind: done.data.kind } - }) +/** Authoritative feedback payloads in log order. */ +function feedbackTexts(session: Session): string[] { + return session.events + .filter(event => event.type === 'feedback/record') + .map(event => event.data.text) } describe('@deepseek-ai/dsh-command-feedback registration', () => { @@ -83,7 +79,7 @@ describe('@deepseek-ai/dsh-command-feedback registration', () => { description: 'record feedback about this session', input: { hint: '' }, }) - expect(test.ctx.commands.find(test.agent, 'feedback')).toBeDefined() + expect(test.ctx.commands.find(test.agent, 'feedback')).toMatchObject({ recordInput: false }) await test.plugin.dispose() expect(test.ctx.commands.find(test.agent, 'feedback')).toBeUndefined() @@ -91,38 +87,47 @@ describe('@deepseek-ai/dsh-command-feedback registration', () => { }) describe('/feedback human command', () => { - it('acknowledges feedback and leaves the registry record as its durable trace', async () => { + it('acknowledges feedback and records its payload exactly once in the domain event', async () => { const test = await harness() await expect(run(test, ' the diff view is unreadable')).resolves.toEqual({ kind: 'success', text: 'Feedback recorded.', }) - expect(commandRecords(test.session)).toEqual([ - { name: 'feedback', args: ' the diff view is unreadable', kind: 'success' }, - ]) + expect(feedbackTexts(test.session)).toEqual(['the diff view is unreadable']) + const commandRun = test.session.events.find(event => event.type === 'command/run') + expect(commandRun?.type === 'command/run' && Object.hasOwn(commandRun.data, 'args')).toBe(false) + expect(JSON.stringify(test.session.events).match(/the diff view is unreadable/gu)).toHaveLength(1) }) - it('adds no event of its own beyond the registry pairing', async () => { + it('exports a command-independent feedback producer', async () => { + const test = await harness() + commandFeedback.recordFeedback(test.session, ' recorded outside a command ') + expect(test.session.events.map(event => event.type)).toEqual(['feedback/record']) + expect(feedbackTexts(test.session)).toEqual(['recorded outside a command']) + expect(() => { commandFeedback.recordFeedback(test.session, ' \n\t ') }) + .toThrow('feedback text must not be empty') + expect(feedbackTexts(test.session)).toEqual(['recorded outside a command']) + }) + + it('keeps command bookkeeping around the authoritative feedback event', async () => { const test = await harness() await run(test, ' nothing else happens') - // The whole point of the command: record and do nothing. Only the - // registry's own pairing appears, and no turn of model work starts. - expect(test.session.events.map(event => event.type)).toEqual(['command/run', 'command/done']) + expect(test.session.events.map(event => event.type)).toEqual([ + 'command/run', 'feedback/record', 'command/done', + ]) }) - it('records verbatim text, including input that looks like another command', async () => { + it('normalizes surrounding whitespace without parsing command-like content', async () => { const test = await harness() await run(test, ' /plan felt SLOW\n\ttwice today ') - expect(commandRecords(test.session)).toEqual([ - { name: 'feedback', args: ' /plan felt SLOW\n\ttwice today ', kind: 'success' }, - ]) + expect(feedbackTexts(test.session)).toEqual(['/plan felt SLOW\n\ttwice today']) }) it('records each entry separately without replacing earlier ones', async () => { const test = await harness() await run(test, ' first') await run(test, ' second') - expect(commandRecords(test.session).map(record => record.args)).toEqual([' first', ' second']) + expect(feedbackTexts(test.session)).toEqual(['first', 'second']) }) it('records concurrent submissions in dispatch order', async () => { @@ -137,7 +142,7 @@ describe('/feedback human command', () => { { kind: 'success', text: 'Feedback recorded.' }, { kind: 'success', text: 'Feedback recorded.' }, ]) - expect(commandRecords(test.session).map(record => record.args)).toEqual([' first', ' second']) + expect(feedbackTexts(test.session)).toEqual(['first', 'second']) }) it('keeps every recorded event off the model surface and out of derived history', async () => { @@ -160,9 +165,12 @@ describe('/feedback human command', () => { } await expect(run(test)).resolves.toEqual(expected) await expect(run(test, ' \n\t ')).resolves.toEqual(expected) - // Rejected input still leaves the registry's own pairing, settled as an - // error, so no entry is mistaken for accepted feedback. - expect(commandRecords(test.session).map(record => record.kind)).toEqual(['error', 'error']) + expect(feedbackTexts(test.session)).toEqual([]) + const done = test.session.events.filter(event => event.type === 'command/done') + expect(done.map(event => event.data.kind)).toEqual(['error', 'error']) + for (const event of test.session.events) { + if (event.type === 'command/run') expect(Object.hasOwn(event.data, 'args')).toBe(false) + } }) it('records nothing when dispatch rejects an already-cancelled request', async () => { diff --git a/packages/feedback/command-feedback/tests/loader-composition.spec.ts b/packages/feedback/command-feedback/tests/loader-composition.spec.ts index 9aa206f9ad..dbb175d304 100644 --- a/packages/feedback/command-feedback/tests/loader-composition.spec.ts +++ b/packages/feedback/command-feedback/tests/loader-composition.spec.ts @@ -92,11 +92,14 @@ describe('/feedback real Loader composition through cordis.yml', () => { text: 'Feedback text is required. Usage: /feedback ', }) - // The command records itself through the registry and does nothing else. + // The domain event owns the payload; generic command bookkeeping omits it. expect(owner.session.events.map(event => event.type)) - .toEqual(['command/run', 'command/done', 'command/run', 'command/done']) + .toEqual(['command/run', 'feedback/record', 'command/done', 'command/run', 'command/done']) const run = owner.session.events.find(event => event.type === 'command/run') - expect(run?.type === 'command/run' && run.data.args).toBe(' the diff view is unreadable') + expect(run?.type === 'command/run' && Object.hasOwn(run.data, 'args')).toBe(false) + const feedback = owner.session.events.find(event => event.type === 'feedback/record') + expect(feedback?.type === 'feedback/record' && feedback.data.text).toBe('the diff view is unreadable') + expect(JSON.stringify(owner.session.events).match(/the diff view is unreadable/gu)).toHaveLength(1) // Nothing reached the model. expect(owner.session.deriveMessages()).toEqual([]) diff --git a/packages/feedback/command-feedback/tsconfig.json b/packages/feedback/command-feedback/tsconfig.json index 6a27b54d3a..0a99f13f01 100644 --- a/packages/feedback/command-feedback/tsconfig.json +++ b/packages/feedback/command-feedback/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../ui/commands" }, + { + "path": "../../core/session" + }, { "path": "../../support/invariants" } diff --git a/packages/plan/plan-mode/README.i18n.yaml b/packages/plan/plan-mode/README.i18n.yaml index c5a13bee7e..791f77b637 100644 --- a/packages/plan/plan-mode/README.i18n.yaml +++ b/packages/plan/plan-mode/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/plan/plan-mode/README.md -README.md: d3c2c14fe616e1c9b4e33b716570b084db6474cf -README.zh.md: 6d6878c4b0300a716ad16be60fd86bc79f1514ba +README.md: e3a98115d2d9f14fa0bb46e4d867f6b79cbf269d +README.zh.md: f8481cff12992e83af39498908c5ca2624a4f974 diff --git a/packages/plan/plan-mode/README.md b/packages/plan/plan-mode/README.md index d3c2c14fe6..e3a98115d2 100644 --- a/packages/plan/plan-mode/README.md +++ b/packages/plan/plan-mode/README.md @@ -20,7 +20,7 @@ The TUI consumes the plugin-owned `/plan` command; other front doors may drive t ## Session projection -When the composition mounts `ctx.sessionProjections` ([`@deepseek-ai/dsh-session-projection`](../../session-projection/session-projection/README.md)), this package registers the `plan` projection unit under an injected child. The unit folds two event kinds: a `command/run` record named `plan` sets the wanted target (`off` → inactive, anything else → active), and `plan/mode` commits the logged state and clears it; every other event returns the same state reference. `view` derives `{ active, pending }`, where `pending` is true only while an outstanding selection differs from the logged state — a pure replay quantity, so host restarts, other tabs, and cold reads all recover it from the log alone (the `/plan` handler calls `set()` before any failing path, keeping the logged request and the run plane from forking). The key merges into `SessionProjectionMap` from `src/types.ts` (served to host consumers via `./types` and client aggregates via `./client`); the framework drives the unit and carriers serve the value on the history tail page and the `session/projection` push frame. Compositions without the registry are unaffected. +When the composition mounts `ctx.sessionProjections` ([`@deepseek-ai/dsh-session-projection`](../../session-projection/session-projection/README.md)), this package registers the `plan` projection unit under an injected child. The unit folds two event kinds: a `command/run` record named `plan` with recorded `args` sets the wanted target (`off` → inactive, anything else → active), and `plan/mode` commits the logged state and clears it; every other event returns the same state reference. `view` derives `{ active, pending }`, where `pending` is true only while an outstanding selection differs from the logged state — a pure replay quantity, so host restarts, other tabs, and cold reads all recover it from the log alone (the `/plan` handler calls `set()` before any failing path, keeping the logged request and the run plane from forking). The key merges into `SessionProjectionMap` from `src/types.ts` (served to host consumers via `./types` and client aggregates via `./client`); the framework drives the unit and carriers serve the value on the history tail page and the `session/projection` push frame. Compositions without the registry are unaffected. ## Configuration diff --git a/packages/plan/plan-mode/README.zh.md b/packages/plan/plan-mode/README.zh.md index 6d6878c4b0..f8481cff12 100644 --- a/packages/plan/plan-mode/README.zh.md +++ b/packages/plan/plan-mode/README.zh.md @@ -20,7 +20,7 @@ TUI 消费插件拥有的 `/plan` 命令;其他入口可以直接驱动同一 ## 会话投影 -当组合挂载 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session-projection/session-projection/README.md))时,本包在注入子插件下注册 `plan` 投影单元。该单元折叠两种事件:名为 `plan` 的 `command/run` 记录设置目标值(`off` → 未激活,其余 → 激活),`plan/mode` 提交已记录状态并将其清除;其他任何事件返回同一状态引用。`view` 推导 `{ active, pending }`,其中 `pending` 仅在未兑现的选择不同于已记录状态时为 true——它是纯回放量,host 重启、其他标签页与冷读都只凭日志即可恢复(`/plan` 处理器在任何可能失败的路径之前调用 `set()`,使已入日志的请求与运行面不可能分叉)。key 从 `src/types.ts` merge 进 `SessionProjectionMap`(host 消费方经 `./types`、client 聚合经 `./client`);框架驱动单元,载体在历史尾页与 `session/projection` 推送帧上提供该值。未挂注册表的组合不受影响。 +当组合挂载 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session-projection/session-projection/README.md))时,本包在注入子插件下注册 `plan` 投影单元。该单元折叠两种事件:名为 `plan` 且带有已记录 `args` 的 `command/run` 记录设置目标值(`off` → 未激活,其余 → 激活),`plan/mode` 提交已记录状态并将其清除;其他任何事件返回同一状态引用。`view` 推导 `{ active, pending }`,其中 `pending` 仅在未兑现的选择不同于已记录状态时为 true——它是纯回放量,host 重启、其他标签页与冷读都只凭日志即可恢复(`/plan` 处理器在任何可能失败的路径之前调用 `set()`,使已入日志的请求与运行面不可能分叉)。key 从 `src/types.ts` merge 进 `SessionProjectionMap`(host 消费方经 `./types`、client 聚合经 `./client`);框架驱动单元,载体在历史尾页与 `session/projection` 推送帧上提供该值。未挂注册表的组合不受影响。 ## 配置 diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index dc6b788825..9d584dc776 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -234,6 +234,7 @@ export class PlanModeService extends Service { init: () => ({ active: false, wanted: null }), apply: (state, event) => { if (event.type === 'command/run' && event.data.name === 'plan') { + if (event.data.args === undefined) return state const wanted = event.data.args.trim() !== 'off' return wanted === state.wanted ? state : { active: state.active, wanted } } diff --git a/packages/plan/plan-mode/tests/projection.spec.ts b/packages/plan/plan-mode/tests/projection.spec.ts index 7c69417e58..cb662bc227 100644 --- a/packages/plan/plan-mode/tests/projection.spec.ts +++ b/packages/plan/plan-mode/tests/projection.spec.ts @@ -1,9 +1,9 @@ /** * The `plan` projection unit (session-projection RFC's complete example): a * double-event fold over the session log. `command/run` records named `plan` - * set the wanted target (`off` → false, anything else → true); `plan/mode` - * commits and clears it; `view` derives `{ active, pending }` where pending - * is true only while an outstanding selection differs from the logged state. + * with recorded input set the wanted target (`off` → false, anything else + * → true); `plan/mode` commits and clears it. `view` reports pending only + * while an outstanding selection differs from the logged state. * Pending is thereby a pure replay quantity — a cold fold answers it without * the service's in-memory intent. Composition without plan-mode has no `plan` * key; unloading the fiber removes it (HMR safety). @@ -88,6 +88,11 @@ describe('plan projection unit', () => { commandId: CommandId('other-1'), name: 'compact', args: '', source: { kind: 'user' }, }) expect(bench.values().plan).toEqual({ active: true, pending: false }) + // A command lifecycle with omitted input carries no plan selection. + bench.session.append('command/run', { + commandId: CommandId('plan-no-input'), name: 'plan', source: { kind: 'user' }, + }) + expect(bench.values().plan).toEqual({ active: true, pending: false }) runPlanCommand(bench.session, ' off', 1) expect(bench.values().plan).toEqual({ active: true, pending: true }) commitPlanMode(bench.session, false, 1) diff --git a/packages/ui/commands/README.i18n.yaml b/packages/ui/commands/README.i18n.yaml index 5c37ccbb16..339c11a248 100644 --- a/packages/ui/commands/README.i18n.yaml +++ b/packages/ui/commands/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/ui/commands/README.md -README.md: 4ad72cf9e232c8d41e525f42eecde5637032a391 -README.zh.md: bace8f6346ac737a838d802dfc5c6ffe52c56edd +README.md: 77397aadf8dd070d962d1a4f95dea2e4700a6c15 +README.zh.md: 8f02325271548b652b069433bcdb9c1c99de547e diff --git a/packages/ui/commands/README.md b/packages/ui/commands/README.md index 4ad72cf9e2..77397aadf8 100644 --- a/packages/ui/commands/README.md +++ b/packages/ui/commands/README.md @@ -6,9 +6,9 @@ Plugin-owned human-command registry consumed by interactive UI adapters. The [pl ## Service contract -`ctx.commands.register(definition)` registers one lowercase command name, description, optional unstructured-input hint, and abortable handler. A registered command is available to every composed command adapter; a plugin that is incompatible with a deployment does not register there. A plain-context registration is global. A command-producing plugin mounted beneath `agent.ctx` declares its own `commands` injection and creates an exact agent-scoped definition; it shadows a global definition with the same name. This child-injection shape preserves the agent scope without making the core agent loop depend on a UI service. Duplicate names within one layer fail during registration. Every disposer is the exact Cordis effect disposer, and registration or removal notifies every `commands/change` observer so live adapters can refresh discovery; observer failures are logged and cannot veto the registry mutation or starve later observers. +`ctx.commands.register(definition)` registers one lowercase command name, description, optional unstructured-input hint, optional `recordInput` policy, and abortable handler. `recordInput` defaults to true; a command whose authoritative domain event owns the payload sets it to false so `command/run` omits `args` instead of duplicating the input. A registered command is available to every composed command adapter; a plugin that is incompatible with a deployment does not register there. A plain-context registration is global. A command-producing plugin mounted beneath `agent.ctx` declares its own `commands` injection and creates an exact agent-scoped definition; it shadows a global definition with the same name. This child-injection shape preserves the agent scope without making the core agent loop depend on a UI service. Duplicate names within one layer fail during registration. Every disposer is the exact Cordis effect disposer, and registration or removal notifies every `commands/change` observer so live adapters can refresh discovery; observer failures are logged and cannot veto the registry mutation or starve later observers. -`list(agent)` returns immutable, name-sorted descriptors after scoped shadowing. `find(agent, name)` returns the corresponding definition. `execute(agent, line, signal)` uses `parseCommand()` and runs only a known command, returning the settled `CommandExecution` (the normalized result plus the lifecycle pairing `commandId`) or `undefined` for invalid syntax or unknown names. A resolved command's lifecycle is logged on the receiving agent's session as the log-only pair `command/run` (before the handler, with a minted `commandId`, the parser's structured `name`/`args` split, and the issuing `CommandSource`) and `command/done` (at settlement, with the outcome kind and verbatim text; a thrown or aborted handler settles as `kind: 'error'`). Admission misses log nothing. Both are direct standalone appends on the receiving agent's session: no turn wraps them, and persistence drains them through ordinary checkpoints and teardown. +`list(agent)` returns immutable, name-sorted descriptors after scoped shadowing. `find(agent, name)` returns the corresponding definition. `execute(agent, line, signal)` uses `parseCommand()` and runs only a known command, returning the settled `CommandExecution` (the normalized result plus the lifecycle pairing `commandId`) or `undefined` for invalid syntax or unknown names. A resolved command's lifecycle is logged on the receiving agent's session as the log-only pair `command/run` (before the handler, with a minted `commandId`, the parser's structured name, the issuing `CommandSource`, and `args` unless `recordInput` is false) and `command/done` (at settlement, with the outcome kind and verbatim text; a thrown or aborted handler settles as `kind: 'error'`). Admission misses log nothing. Both are direct standalone appends on the receiving agent's session: no turn wraps them, and persistence drains them through ordinary checkpoints and teardown. `parseCommand()` recognizes a slash at byte zero, a lowercase name containing letters, digits, `_`, or `-`, and either end-of-input or whitespace. It returns every byte after the name as `rawInput`, including separator whitespace; consumers own their command-specific grammar and may normalize only what that grammar permits. diff --git a/packages/ui/commands/README.zh.md b/packages/ui/commands/README.zh.md index bace8f6346..8f02325271 100644 --- a/packages/ui/commands/README.zh.md +++ b/packages/ui/commands/README.zh.md @@ -6,9 +6,9 @@ ## 服务契约 -`ctx.commands.register(definition)` 注册一个小写命令名称、描述、可选的非结构化输入提示,以及可中止的处理器。每个已注册命令都可供所有已组合的命令适配器使用;与某项部署不兼容的插件不会在此注册。普通上下文中的注册全局生效。在 `agent.ctx` 下挂载的命令生产插件会声明自身的 `commands` 注入,并创建精确限定到该 agent 的定义;该定义会遮蔽同名的全局定义。这种子级注入形态保留了 agent 作用域,同时不会让核心 agent loop 依赖 UI 服务。同一层中的名称重复会在注册时失败。每个 disposer 都是 Cordis effect 返回的确切 disposer;注册或移除命令时,系统会通知每个 `commands/change` 观察者,使实时适配器能够刷新发现结果。观察者失败会写入日志,既不能否决注册表变更,也不能阻止后续观察者运行。 +`ctx.commands.register(definition)` 注册一个小写命令名称、描述、可选的非结构化输入提示、可选的 `recordInput` 策略,以及可中止的处理器。`recordInput` 默认为 true;若载荷由命令的权威领域事件持有,该命令会将 `recordInput` 设为 false,让 `command/run` 省略 `args`,避免重复记录输入。每个已注册命令都可供所有已组合的命令适配器使用;与某项部署不兼容的插件不会在此注册。普通上下文中的注册全局生效。在 `agent.ctx` 下挂载的命令生产插件会声明自身的 `commands` 注入,并创建精确限定到该 agent 的定义;该定义会遮蔽同名的全局定义。这种子级注入形态保留了 agent 作用域,同时不会让核心 agent loop 依赖 UI 服务。同一层中的名称重复会在注册时失败。每个 disposer 都是 Cordis effect 返回的确切 disposer;注册或移除命令时,系统会通知每个 `commands/change` 观察者,使实时适配器能够刷新发现结果。观察者失败会写入日志,既不能否决注册表变更,也不能阻止后续观察者运行。 -`list(agent)` 在应用作用域遮蔽后,返回按名称排序的不可变描述符。`find(agent, name)` 返回相应定义。`execute(agent, line, signal)` 使用 `parseCommand()`,且只运行已知命令,返回已结算的 `CommandExecution`(规范化结果加生命周期配对 `commandId`);语法无效或名称未知时返回 `undefined`。已解析命令的生命周期会以 log-only 事件对的形式记录在接收 agent 的会话日志中:`command/run`(进入处理器前记录,携带铸造的 `commandId`、解析器的结构化 `name`/`args` 切分和发起方 `CommandSource`)与 `command/done`(结算时记录,携带结局种类与原样文本;处理器抛出或被中止时以 `kind: 'error'` 结算)。未通过准入的输入不记录任何事件。两者都是直接独立追加:没有轮次包裹它们,持久化在常规检查点与 teardown 时排空它们。 +`list(agent)` 在应用作用域遮蔽后,返回按名称排序的不可变描述符。`find(agent, name)` 返回相应定义。`execute(agent, line, signal)` 使用 `parseCommand()`,且只运行已知命令,返回已结算的 `CommandExecution`(规范化结果加生命周期配对 `commandId`);语法无效或名称未知时返回 `undefined`。已解析命令的生命周期会以 log-only 事件对的形式记录在接收 agent 的会话日志中:`command/run`(进入处理器前记录,携带铸造的 `commandId`、解析器得到的结构化名称、发起方 `CommandSource`,以及 `args`(`recordInput` 为 false 时省略))与 `command/done`(结算时记录,携带结局种类与原样文本;处理器抛出或被中止时以 `kind: 'error'` 结算)。未通过准入的输入不记录任何事件。两者都是直接独立追加:没有轮次包裹它们,持久化在常规检查点与 teardown 时排空它们。 `parseCommand()` 识别位于字节零位置的斜杠、由小写字母、数字、`_` 或 `-` 构成的名称,以及名称后紧接输入末尾或空白的形式。它将名称后的每个字节作为 `rawInput` 返回,其中包括分隔空白;消费方拥有各命令专用的语法,只能执行该语法允许的规范化。 diff --git a/packages/ui/commands/src/index.ts b/packages/ui/commands/src/index.ts index b1a5121243..b6dea581eb 100644 --- a/packages/ui/commands/src/index.ts +++ b/packages/ui/commands/src/index.ts @@ -71,6 +71,12 @@ export interface CommandDefinition { readonly description: string /** Optional free-form input hint advertised to capable clients. */ readonly input?: CommandInputDescriptor + /** + * Whether `command/run` records `rawInput`. Defaults to true. A command + * whose domain event owns the payload sets this false to avoid duplicating + * that payload in the session log. + */ + readonly recordInput?: boolean /** Execute against the receiving agent without sending the command to the model. */ readonly handler: (invocation: CommandInvocation) => CommandResult | Promise } @@ -127,9 +133,10 @@ declare module '@deepseek-ai/dsh-session' { * and `args` are `parseCommand`'s own split (name and verbatim rawInput, * separator whitespace included), so a consumer (a projection unit * folding its own command records, a rich command card) never re-parses - * a line. + * a line. `args` is absent when the definition sets `recordInput: false` + * because an authoritative domain event owns the input payload. */ - 'command/run': { commandId: CommandId; name: string; args: string; source: CommandSource } + 'command/run': { commandId: CommandId; name: string; args?: string; source: CommandSource } /** * The paired command settled. `kind`/`text` carry the handler's verbatim * outcome (a thrown/aborted handler settles as `kind: 'error'` with the @@ -239,6 +246,7 @@ function normalizeDefinition(definition: CommandDefinition): RegisteredCommand { name: definition.name, description: definition.description, ...input === undefined ? {} : { input }, + ...definition.recordInput === undefined ? {} : { recordInput: definition.recordInput }, handler: definition.handler, }) const descriptor = Object.freeze({ @@ -357,7 +365,10 @@ export class CommandService extends Service { if (signal.aborted) throw abortError(signal) const commandId = this.mintCommandId() this.appendLifecycle(agent.session, 'command/run', { - commandId, name: parsed.name, args: parsed.rawInput, source: { kind: 'user' }, + commandId, + name: parsed.name, + ...command.definition.recordInput === false ? {} : { args: parsed.rawInput }, + source: { kind: 'user' }, }) const invocation = Object.freeze({ agent, rawInput: parsed.rawInput, signal }) let result: CommandResult diff --git a/packages/ui/commands/tests/commands.spec.ts b/packages/ui/commands/tests/commands.spec.ts index f22e974d58..b85971f5d7 100644 --- a/packages/ui/commands/tests/commands.spec.ts +++ b/packages/ui/commands/tests/commands.spec.ts @@ -320,6 +320,25 @@ describe('CommandService', () => { ]) }) + it('omits raw input from command/run when an authoritative domain event owns it', async () => { + const ctx = await mount() + const { agent } = await mintAgentScope(ctx, 'a') + const seen = vi.fn(() => ({ kind: 'success' as const })) + ctx.commands.register({ + name: 'private', + description: 'Record privately', + recordInput: false, + handler: seen, + }) + + await ctx.commands.execute(agent, '/private keep this once', new AbortController().signal) + + expect(seen).toHaveBeenCalledWith(expect.objectContaining({ rawInput: ' keep this once' })) + const run = agent.session.events.find(event => event.type === 'command/run') + expect(run?.type).toBe('command/run') + expect(run?.type === 'command/run' && Object.hasOwn(run.data, 'args')).toBe(false) + }) + it('mints distinct monotonic commandIds across executions', async () => { const ctx = await mount() const { agent } = await mintAgentScope(ctx, 'a') From b8d51704f343d730fda933148e0c3a0f3176bb1f Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 21:43:31 +0800 Subject: [PATCH 003/104] docs: refresh feedback module graph --- docs/module-graph.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index 511e132360..61477ae104 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -618,6 +618,7 @@ flowchart TD pkg_client_ui_goal --> pkg_invariants pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants + pkg_command_feedback --> pkg_session pkg_pty_local --> pkg_agent pkg_pty_local --> pkg_invariants pkg_pty_local --> pkg_pty @@ -1092,7 +1093,7 @@ flowchart TD | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`user-approval`](../packages/ui/user-approval) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | -| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants) | +| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) | From 00390ae851b838c50e981049156d1a54b8176ce2 Mon Sep 17 00:00:00 2001 From: ZiyaZhang Date: Fri, 31 Jul 2026 12:07:43 -0700 Subject: [PATCH 004/104] feat(web): open a produced file from the conversation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serve one file at a time out of a Session's workspace under /f on the web transport, and point the conversation's existing file-open affordance at it. Clicking a write/edit/read row's path now opens that file in a browser tab — including from a LAN client, where the Host's system opener is fenced to loopback and answered nothing. - /f// in client-connection, behind the same browser-trust fence as /api; realpath confinement, streamed reads, GET/HEAD only, nosniff + no-store. - Script-capable documents carry CSP sandbox: model-authored markup must not be same-origin with /api, where events.mux is a readable GET stream. - ApiProxy.workspaceRootOf answers where a Session's files live without resuming an agent; the client program cannot reach the core services. - The /f URL shape lives in dsh-host-apiproxy/api so both ends share one encoding (client bundles may not value-import another plugin). --- ...6-07-31-web-workspace-file-links.i18n.yaml | 6 + .../2026-07-31-web-workspace-file-links.md | 38 ++++ .../2026-07-31-web-workspace-file-links.zh.md | 38 ++++ apps/web/tests/workspace-file-open.e2e.ts | 92 ++++++++++ apps/web/tsconfig.json | 3 +- docs/config-catalog.md | 2 +- packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 8 +- packages/client/connection/README.zh.md | 8 +- .../client/connection/src/client/fixture.ts | 5 + packages/client/connection/src/index.ts | 44 ++++- .../client/connection/src/workspace-files.ts | 169 ++++++++++++++++++ .../client/connection/tests/node-half.spec.ts | 78 +++++++- .../connection/tests/workspace-files.spec.ts | 134 ++++++++++++++ .../runtime/src/client/contract/workspaces.ts | 11 ++ .../runtime/src/client/workspaces/service.ts | 14 ++ .../runtime/tests/workspaces-service.spec.ts | 15 ++ .../client/test-runtime/src/workspaces.ts | 17 ++ .../test-runtime/tests/runtime.spec.tsx | 9 +- .../ui-conversation/src/client/apply.ts | 9 + .../tests/apply-inject.spec.tsx | 13 +- .../tests/chat-code-subcalls.spec.tsx | 5 +- .../tests/chat-toolview-slot.spec.tsx | 6 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 + packages/host/apiproxy/README.zh.md | 2 + packages/host/apiproxy/src/api-proxy.ts | 11 ++ packages/host/apiproxy/src/api/files.ts | 98 ++++++++++ packages/host/apiproxy/src/api/index.ts | 18 ++ packages/host/apiproxy/src/index.ts | 2 + .../tests/api-proxy-workspace.spec.ts | 32 +++- .../apiproxy/tests/client-handler.spec.ts | 2 + .../host/apiproxy/tests/fetch-carrier.spec.ts | 2 + .../host/apiproxy/tests/files-path.spec.ts | 74 ++++++++ tsconfig.host.json | 1 + 35 files changed, 946 insertions(+), 30 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md create mode 100644 .agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md create mode 100644 apps/web/tests/workspace-file-open.e2e.ts create mode 100644 packages/client/connection/src/workspace-files.ts create mode 100644 packages/client/connection/tests/workspace-files.spec.ts create mode 100644 packages/host/apiproxy/src/api/files.ts create mode 100644 packages/host/apiproxy/tests/files-path.spec.ts 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 new file mode 100644 index 0000000000..2055af6cea --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.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-web-workspace-file-links.md +2026-07-31-web-workspace-file-links.md: b7fd5ca240db3ca885e89f4cf6dcc135e7c88de8 +2026-07-31-web-workspace-file-links.zh.md: 74949afe0260d2d9018691740573ff24a1bce820 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 new file mode 100644 index 0000000000..b7fd5ca240 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md @@ -0,0 +1,38 @@ +# Agent Note: opening a produced file from the web UI + +Status: implemented + +English | [中文](2026-07-31-web-workspace-file-links.zh.md) + +> Scope: the `/f` workspace-file route on the web transport, the `IWorkspaces.fileUrl` derivation behind it, and the conversation's file-open affordance switching to it. Not in scope: an artifact registry, versioning, live reload, or any model-facing declaration. + +## Problem + +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. + +The parts were nearly all present, pointed at the wrong target. `ToolRow` already renders a mutation or read row's path as a real button, `ui-conversation` already routes its click through `openFile`, and `workspaces.openPath` already carries it to the Host's system opener. But that opener runs on the Host machine, and `host.openPath` is loopback-pinned by the `/api` trust fence, so the affordance answered nothing for a browser reached over the LAN and was invisible even locally (the path styled as plain text, underlined only on hover). Meanwhile `MarkdownText` strips every non-`http(s)` URL, so a path the model wrote into its closing message could never become a link at all, and `ToolCallView.locations` — the follow-along vocabulary the file tools already populate — had no consumer in the client. + +## Decision + +**One prefix route on the transport that already exists, not a new capability.** `client-connection` owns both browser-facing prefixes: `/api` for RPC and `/f//` for workspace-file reads. It was already the package holding `httpServer`, the `trustedHosts` config, and the browser-trust fence; a separate package would have duplicated the fence and the config, and forced `AppCLIEntry` to patch two rows for one `--trusted-host` flag. The webserver's own contract — every feature surface is a route some other plugin registers — makes the route the whole mechanism. Segments ride the path rather than a query parameter so a served document's relative references resolve to its siblings. + +**The request names a Session; the gateway names the authority.** `ApiProxy.workspaceRootOf` answers where a Session's files live — a live agent's `session.header.cwd` first, then the persistence store, never a resume — as a second, non-envelope face of the `cwd` the session summaries already carry. The route reads that instead of `ctx.agents` directly, because `client-connection` is registered in the client program and importing the core service packages merges their host-side `sessions: SessionStore` declaration over the browser runtime's own `sessions: SessionsService` — the collision `tsconfig.host.json`/`tsconfig.client.json` exist to prevent. Both the cwd and the resolved target go through `realpath` before the prefix comparison, so a workspace-internal symlink pointing outward is refused by its target; traversal spellings are refused at parse time, before any filesystem call. Reads stream through `pipeline`, so a client that goes away destroys the descriptor and no request ever buffers a file. + +**The URL shape lives in `dsh-host-apiproxy/api`, with the other browser-importable contract surfaces.** Both ends must agree on one encoding, but a client bundle may not value-import another plugin's package: the purity gate in `packages/client/tsdown.client.ts` allows only platform modules and the `INLINE_SAFE` wire layers, of which apiproxy is one. Putting `api/files.ts` there is what lets the browser half build a URL and the serving half parse it from a single source, and it needed no new package edge — both sides already depend on apiproxy. + +**Model-authored documents are served into an opaque origin.** `.html`/`.htm`/`.xhtml`/`.svg` carry `Content-Security-Policy: sandbox allow-scripts allow-popups allow-modals allow-forms`. Serving generated markup same-origin with `/api` would put `/api/events.mux` — a readable `GET` stream — one `window.open` away from a page the model wrote. The sandbox costs the preview its `localStorage`, cookies, and same-origin `fetch`; `host.openPath` stays as the full-capability way to open the same file on the Host machine, so the trade is resolved by keeping both affordances rather than by weakening either. + +**The client decides by derivation, not by probing.** `IWorkspaces.fileUrl(sessionId, cwd, path)` expresses a tool-reported path as segments below the session cwd and returns the origin-relative URL, or `undefined` when the path leaves the workspace. `undefined` is exactly the signal to fall back to `openPath`, so a file outside the workspace behaves as it did before and no capability negotiation is needed. + +## Alternatives considered + +- **The artifact capability family (RFC #268 / PR #272)** — a seam with ids, versions, snapshot storage, its own HTTP server, SSE live reload, and a browser auto-opener. Its review found seven critical issues, and every one of them came from that machinery: an unlistened opener spawn crashing the harness, the opener inheriting `DEEPSEEK_API_KEY`, in-flight publishes outliving disposal, `readFile` preceding the size cap, a snapshot TOCTOU, and retention leaking with undisposed agents. `dsh web` already runs an HTTP server and the user is already in a browser, so none of that machinery buys anything here. The RFC and its tests stay as the input for the day a real cross-session or versioned-artifact need appears; this route is that seam's natural mount point when it does. +- **A dedicated `dsh-client-workspace-files` package** — the honest seam shape if file serving were an independent capability. It is not: it needs the same fence and the same `trustedHosts` value as `/api`, and splitting would have duplicated both against the repository's own "don't split preemptively" rule. +- **Keeping the URL-shape module in `client-connection` and importing it from the runtime** — the first cut, and the build refused it: a cross-plugin value import into a client bundle either inlines a duplicate runtime instance or names a specifier the frozen module table cannot answer. The gate is the reason the shared module sits in the wire layer rather than in the package that happens to own the route. +- **`/f/`, so `openPath` could stay the single call site** — drops the sessionId from the URL, but then the served authority becomes the union of every workspace the host knows. The tight authority costs exactly one call-site edit, because `openFile` already has both the sessionId and the cwd in scope. +- **`connect-src 'none'` instead of `sandbox`, to keep `localStorage` working** — blocks `fetch`/`EventSource` but not `window.open('/api/events.mux')`, which is readable same-origin. The two GET SSE endpoints are what make the sandbox necessary rather than optional. +- **Linkifying paths in the assistant's closing message** — the shape a user asks for ("put the link at the end"), but it makes rendering depend on the model spelling a path recognizably. The tool calls already carry `locations` as structured fact; consuming that is the reliable source and is left as the follow-up this route unblocks. + +## Consequences + +Every existing file affordance changed target at once: write, edit, read, and the generic single-file card all reach `openFile`, so one call-site edit made produced files openable in the browser, LAN clients included. Three tests asserting the old `openPath` destination were rewritten to the new one; the outside-workspace fallback keeps the old assertion. The route is covered against a real HTTP server and a real temporary workspace, because confinement, content typing, and the sandbox header are wire facts, and the assembled web lane (`apps/web/tests/workspace-file-open.e2e.ts`, keyless over a cold-seeded session) proves the product path: clicking a read row's path opens `/f//a.txt` in a second tab serving that workspace file, while a traversal spelling answers 404. `localStorage` is unavailable inside a preview, which is visible on generated pages that persist a theme toggle — the Host opener remains for those. Still deferred: the end-of-turn deliverable row derived from `locations`, and any linkification inside assistant Markdown. 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 new file mode 100644 index 0000000000..74949afe02 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md @@ -0,0 +1,38 @@ +# Agent Note:从 web UI 打开产出的文件 + +Status: implemented + +[English](2026-07-31-web-workspace-file-links.md) | 中文 + +> 范围:web 传输层上的 `/f` 工作区文件路由、其背后的 `IWorkspaces.fileUrl` 推导,以及会话中打开文件的交互改指向它。不在范围内:产物注册表、版本、实时重载,或任何面向模型的声明。 + +## 问题 + +一个产出了文件的 web 会话,没有办法看到那个文件。agent 写出了 `deepseek-homepage.html` 并如实告知,而用户唯一的办法是把 `/private/tmp/dsh-client-hotplug.ygPvsm/workspaces/plugin-hotplug/deepseek-homepage.html` 这样的绝对路径复制进终端。 + +零件几乎都在,只是指错了目标。`ToolRow` 早已把改写行或读取行的路径渲染成一个真正的按钮,`ui-conversation` 早已把它的点击经由 `openFile` 转发,`workspaces.openPath` 也早已把它送到 Host 的系统打开器。但那个打开器运行在 Host 机器上,而 `host.openPath` 被 `/api` 信任 fence 钉在回环,所以这个交互对经 LAN 访问的浏览器什么都答不了,即便在本机也是隐形的(路径的样式就是普通文本,只有 hover 时才有下划线)。与此同时 `MarkdownText` 会剥掉每一个非 `http(s)` 的 URL,因此模型写进收尾消息里的路径根本不可能成为链接;而 `ToolCallView.locations`——文件工具早已填好的跟随文件词汇——在客户端没有任何消费方。 + +## 决定 + +**在已有的传输层上加一条前缀路由,而不是加一项能力。** `client-connection` 持有两条面向浏览器的前缀:`/api` 承载 RPC,`/f//` 承载工作区文件读取。它本来就是持有 `httpServer`、`trustedHosts` 配置和浏览器信任 fence 的那个包;单开一个包会把 fence 和配置各复制一份,并逼着 `AppCLIEntry` 为一个 `--trusted-host` 标志去 patch 两行。webserver 自己的契约——每个特性面都是别的插件注册的一条路由——让这条路由本身就是全部机制。段落走路径而非查询参数,是为了让所服务文档的相对引用能解析到它的同级文件。 + +**请求指名 Session,由网关指名权限边界。** `ApiProxy.workspaceRootOf` 回答某个 Session 的文件位于何处——先看活跃 agent 的 `session.header.cwd`,再看持久化存储,绝不恢复会话——它是会话摘要早已携带的那个 `cwd` 的第二副面孔,只是不带信封。路由读取它而不是直接够 `ctx.agents`,因为 `client-connection` 注册在 client 程序里,而引入核心服务包会把它们 host 侧的 `sessions: SessionStore` 声明盖到浏览器运行时自己的 `sessions: SessionsService` 之上——这正是 `tsconfig.host.json`/`tsconfig.client.json` 分立所要防的那种冲突。cwd 与解析出的目标在前缀比较前都要过 `realpath`,因此工作区内指向工作区外的符号链接会因其目标而被拒绝;穿越写法在解析期就被拒,早于任何文件系统调用。读取经 `pipeline` 流出,因此客户端离开即销毁描述符,任何请求都不会把文件缓冲起来。 + +**URL 形状落在 `dsh-host-apiproxy/api`,与其余浏览器可导入的契约面同处一地。** 两端必须就同一套编码达成一致,但客户端 bundle 不允许值导入另一个插件的包:`packages/client/tsdown.client.ts` 里的纯度 gate 只放行平台模块与 `INLINE_SAFE` 协议层,而 apiproxy 正是其中之一。把 `api/files.ts` 放在那里,才使构造 URL 的浏览器半侧与解析它的服务半侧共用单一来源,而且没有新增任何包依赖边——两侧本来就依赖 apiproxy。 + +**模型撰写的文档被送进不透明源。** `.html`/`.htm`/`.xhtml`/`.svg` 会带上 `Content-Security-Policy: sandbox allow-scripts allow-popups allow-modals allow-forms`。若把生成的标记与 `/api` 同源提供,`/api/events.mux`——一条可读的 `GET` 流——离模型写的页面就只有一次 `window.open` 之遥。sandbox 让预览失去 `localStorage`、cookie 与同源 `fetch`;`host.openPath` 作为在 Host 机器上以完整能力打开同一文件的方式保留下来,因此这个取舍是靠同时保留两个交互解决的,而不是靠削弱其中之一。 + +**客户端靠推导决定,而不是靠探测。** `IWorkspaces.fileUrl(sessionId, cwd, path)` 把工具报告的路径表达为 session cwd 之下的段落并返回相对于源的 URL,路径离开工作区时返回 `undefined`。`undefined` 恰好就是回退到 `openPath` 的信号,因此工作区外的文件行为与以往一致,也不需要任何能力协商。 + +## 考虑过的替代方案 + +- **产物能力族(RFC #268 / PR #272)**——一条带 id、版本、快照存储、自有 HTTP 服务器、SSE 实时重载与浏览器自动打开器的 seam。它的评审给出了七个 critical,而每一个都来自那套机械结构:未监听的打开器 spawn 会让 harness 崩溃、打开器继承 `DEEPSEEK_API_KEY`、进行中的 publish 活过 dispose、`readFile` 先于大小上限、快照的 TOCTOU,以及未 dispose 的 agent 导致保留期泄漏。`dsh web` 本来就跑着一个 HTTP 服务器,用户本来就在浏览器里,那套机械结构在这里买不到任何东西。RFC 与其测试保留下来,作为真正出现跨会话或版本化产物需求那天的输入;届时这条路由就是那条 seam 的天然挂载点。 +- **单开一个 `dsh-client-workspace-files` 包**——如果文件服务是一项独立能力,这才是诚实的 seam 形状。它不是:它需要与 `/api` 相同的 fence 和相同的 `trustedHosts` 值,拆分会把两者都复制一份,违背仓库自己的“不要预先拆分”。 +- **把 URL 形状模块留在 `client-connection` 里、由 runtime 去导入**——最初就是这么写的,构建直接拒绝:向客户端 bundle 做跨插件值导入,要么内联出一份重复的运行时实例,要么落到冻结模块表答不出的说明符上。这道 gate 正是共享模块落在协议层、而非落在恰好持有该路由的那个包里的原因。 +- **`/f/<绝对路径>`,好让 `openPath` 保持为唯一调用点**——这会把 sessionId 从 URL 里去掉,但所服务的权限边界随之变成 host 已知的全部工作区之并集。紧的权限边界只花掉一处调用点的改动,因为 `openFile` 本来就同时持有 sessionId 与 cwd。 +- **用 `connect-src 'none'` 代替 `sandbox`,以保住 `localStorage`**——它挡得住 `fetch`/`EventSource`,挡不住 `window.open('/api/events.mux')`,而后者是同源可读的。正是那两个 GET SSE 端点让 sandbox 成为必需而非可选。 +- **把路径在助手的收尾消息里链接化**——这是用户开口要的形状(“在结尾附上链接”),但它让渲染取决于模型是否把路径拼写得可识别。工具调用已经把 `locations` 作为结构化事实携带;消费它才是可靠来源,作为这条路由解锁的后续留下。 + +## 影响 + +现有的每一处文件交互都同时换了目标:write、edit、read 与通用单文件卡片都汇到 `openFile`,因此一处调用点的改动就让产出的文件在浏览器里可打开,LAN 客户端也在内。三个断言旧 `openPath` 去向的测试被改写为新的去向;工作区外的回退保留了旧断言。这条路由对着真实 HTTP 服务器与真实临时工作区做覆盖,因为收敛、内容定型与 sandbox 头都是协议事实;而组装后的 web 通道(`apps/web/tests/workspace-file-open.e2e.ts`,在冷播种会话上无密钥运行)证明了产品路径:点击读取行的路径会在第二个标签页打开 `/f//a.txt` 并提供那个工作区文件,而穿越写法应答 404。预览中无法使用 `localStorage`,这在会持久化主题切换的生成页面上是看得见的——那些场景仍有 Host 打开器。仍然暂缓:由 `locations` 推导的回合末交付物行,以及助手 Markdown 内部的任何链接化。 diff --git a/apps/web/tests/workspace-file-open.e2e.ts b/apps/web/tests/workspace-file-open.e2e.ts new file mode 100644 index 0000000000..63d4266cf4 --- /dev/null +++ b/apps/web/tests/workspace-file-open.e2e.ts @@ -0,0 +1,92 @@ +// Web e2e scenario: clicking a tool row's file path opens that file in a new +// browser tab, served by the web transport's own /f route. Cold-seeds the +// seeded-history fixture (zero model calls). The surface package tests can +// assert which opener the click reaches, but only the assembled application +// proves the opened URL actually serves the workspace file — the whole point +// of the route (docs/testing.md snapshot rule). +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + fixtureUserPrompts, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +// Borrowed read-only: this scenario needs any settled turn whose tool rows +// carry a workspace file path, not a new recording (message-actions pattern). +const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url)) +const MODE = webSnapshotMode() +const SEED_ID = 'workspace-file-open-web-e2e' + +const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.' + +describe('web e2e: opening a workspace file from a tool row', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + // The seeded Session's cwd is the scaffold workspace itself; the recording's + // own nested directory is written too, so the seed's paths stay resolvable. + await mkdir(join(scaffold.workspaceCwd, 'workspace'), { recursive: true }) + for (const dir of [scaffold.workspaceCwd, join(scaffold.workspaceCwd, 'workspace')]) { + await writeFile(join(dir, 'a.txt'), 'alpha\n') + await writeFile(join(dir, 'b.txt'), 'beta\n') + } + const raw = await readFile(SEED, 'utf8') + expect(fixtureUserPrompts(raw), 'borrowed seed must carry the drive prompt').toEqual([PROMPT]) + await seedSession(scaffold, raw, SEED_ID) + 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 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it.skipIf(MODE === 'record')('opens the read row’s file in a new tab, served from the session workspace', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-workspace-file-open')) + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1) + + // The row summary IS the link: a button whose label is the tool's path. + const fileLink = page.getByRole('button', { name: 'a.txt', exact: true }).first() + await fileLink.waitFor({ timeout: 10_000 }) + const [opened] = await Promise.all([ + page.context().waitForEvent('page', { timeout: 15_000 }), + fileLink.click(), + ]) + await opened.waitForLoadState('domcontentloaded') + expect(new URL(opened.url()).pathname).toBe(`/f/${SEED_ID}/a.txt`) + expect(await opened.locator('body').innerText()).toContain('alpha') + + // The served response is a workspace read, not a download, and never cached + // past the turn that produced it. + const served = await page.request.get(opened.url()) + expect(served.status()).toBe(200) + expect(served.headers()['x-content-type-options']).toBe('nosniff') + expect(served.headers()['cache-control']).toBe('no-store') + + // Nothing outside the Session's workspace is reachable through the route. + const escape = await page.request.get(`${scaffold.baseUrl}/f/${SEED_ID}/..%2Fetc%2Fhosts`) + expect(escape.status()).toBe(404) + + await opened.close() + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 90_000) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index c795dc7aef..2c65f1e510 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -50,7 +50,8 @@ "tests/permission-policy-context.e2e.ts", "tests/access-confirmation.e2e.ts", "tests/shipped-composition.e2e.ts", - "tests/startup-auto-selection.e2e.ts" + "tests/startup-auto-selection.e2e.ts", + "tests/workspace-file-open.e2e.ts" ], "references": [ { diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a356a151e3..114e2bdb35 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -296,7 +296,7 @@ export interface ConnectionConfig { } ``` -Source: [`packages/client/connection/src/index.ts:20`](../packages/client/connection/src/index.ts) +Source: [`packages/client/connection/src/index.ts:26`](../packages/client/connection/src/index.ts) ## `@deepseek-ai/dsh-client-hmr` diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 974e3014d6..101d8fd61b 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: c8b7c4787cbcbf6a202fb944459a589fcadd7c8d -README.zh.md: 693420183ffa4fb20e1fecbff523a12261a45d45 +README.md: 9a08cb4de5531b044bd411ea08595c22f88e5f8a +README.zh.md: cfc427945f42b61288f57f5ca1db9af74dbcfb31 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index c8b7c4787c..9a08cb4de5 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -2,12 +2,18 @@ English | [中文](README.zh.md) -Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. +Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The node half owns both browser-facing prefixes — `/api` for RPC and `/f` for workspace-file reads — behind one trust fence. The `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. ## /api browser-trust fence The node half guards every request under `/api` before bridging (`src/api-request-trust.ts`). Every request — browser-marked or not — must present a `Host` that is a loopback authority or matches a `trustedHosts` entry: exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense). There is deliberately no shortcut for requests without browser markers: 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 still be a rebound browser read with a readable response, and Host is the one header rebinding cannot forge; non-browser clients pass the same fence via loopback, the CLI-derived LAN IP literals, or a declared authority. When markers are present, an attached `Origin` must equal the Host authority, and an explicit `sec-fetch-site: cross-site` marker is refused. A `trustedHosts` entry that is not a bare, canonical `host[:port]` authority — one WHATWG parsing reads back exactly as written — fails the plugin load loudly: parsing would otherwise quietly authorize the hostname inside `harness.internal/path`, or broaden a dangling-colon or zero-padded port to an any-port grant. Failures answer plain 403 before any RPC dispatch. A non-loopback (`--host 0.0.0.0`) deployment therefore needs its serving authorities trusted: the dsh CLI derives the machine's LAN IP literals itself and its `--trusted-host` flag declares named ones, so `trustedHosts` in cordis.yml is for compositions the CLI does not boot. The fence is deliberately not an authentication layer — reachability policy stays with the webserver binding, and auth remains deferred work. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md). +## /f workspace-file reads + +The node half also serves one file at a time out of a Session's workspace under `/f//`, so a produced deliverable is reachable from the page that reported it — an `http` page cannot follow a `file://` link, and a browser that is not on the Host machine has no such path anyway. The segments ride the URL rather than a query parameter so a served document's relative references resolve to its siblings. The request names a Session and the gateway names that Session's directory (`ApiProxy.workspaceRootOf`, which answers from a live agent's header or the persistence store and never resumes an agent to serve a file); this package reads the authority rather than the core services, because holding their host-side Context declarations would merge them over the browser runtime's own. The URL shape itself lives with the other browser-importable contract surfaces, in [`@deepseek-ai/dsh-host-apiproxy/api`](../../host/apiproxy/README.md), so the browser half that builds a URL and this half that parses one share a single encoding decision. Both the cwd and the resolved target go through `realpath` before comparison, so a symlink inside the workspace pointing out of it is refused by its target rather than its name; traversal spellings are refused earlier still, at parse time, before any filesystem call. Reads stream (no request buffers a file), answer `GET`/`HEAD` only, and carry `nosniff` with `no-store`. Extensions outside the served content-type table are typed `text/plain` rather than offered as a download, because a workspace read is a request to see a file. + +Documents that can execute script — `.html`, `.htm`, `.xhtml`, `.svg` — additionally carry `Content-Security-Policy: sandbox allow-scripts allow-popups allow-modals allow-forms`. Model-authored markup is served from the same origin as `/api`, where `/api/events.mux` is a readable `GET` stream, so an opaque origin is what keeps a generated page from reading the session event stream one `window.open` away. The cost is borne by the preview: `localStorage`, cookies, and same-origin `fetch` are unavailable inside it, and `host.openPath` remains the full-capability way to open the same file on the Host machine. The same trust fence gates this prefix, so a `trustedHosts` deployment serves workspace files exactly where it serves ordinary reads. + ## Keyless fixture Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival. Fixture content search preserves the production-facing `unicode61`-style case, diacritic, and token-phrase behavior and returns a match-centered snippet of at most 120 Unicode code points. diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 693420183f..cfc427945f 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -2,12 +2,18 @@ [English](README.md) | 中文 -协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 +协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。node 半侧持有两条面向浏览器的前缀——`/api` 承载 RPC,`/f` 承载工作区文件读取——共用同一道信任 fence。`/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 ## /api 浏览器信任栅栏 node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust.ts`)。每个请求——无论是否带浏览器标记——`Host` 都必须是回环地址权威,或与某个 `trustedHosts` 条目匹配:带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御)。刻意不为无浏览器标记的请求开捷径:明文 HTTP 下浏览器的读取(EventSource、图片、导航——这些头只发给可信目标)既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求仍可能是被重绑页面发起的、响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;非浏览器客户端经由回环地址、CLI 推导的 LAN IP 字面量或已声明的权威通过同一道栅栏。当标记存在时,`Origin` 必须与 Host 权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯的、规范形 `host[:port]` 权威的 `trustedHosts` 条目——即 WHATWG 解析读回后与原文不完全一致的——会让插件加载大声失败:否则解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname,或把悬空冒号、补零端口放大成任意端口授权。失败在任何 RPC 分发之前以纯 403 应答。因此非回环(`--host 0.0.0.0`)部署需要让自己的服务权威被信任:dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏刻意不承担认证职责——可达性策略归 webserver 绑定配置,认证仍是延期工作。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。 +## /f 工作区文件读取 + +node 半侧还会在 `/f//` 下逐个提供某个 Session 工作区里的文件,让产出的交付物能从报告它的那个页面直接抵达——`http` 页面无法跟随 `file://` 链接,而不在 Host 机器上的浏览器本来也没有那条路径。段落走 URL 而非查询参数,是为了让所服务文档的相对引用能解析到它的同级文件。请求指名一个 Session,由网关指名该 Session 的目录(`ApiProxy.workspaceRootOf`,它从活跃 agent 的 header 或持久化存储作答,绝不会为了提供一个文件而恢复 agent);本包读取这个权威来源而不去够核心服务,因为持有它们的 host 侧 Context 声明会把它们盖到浏览器运行时自己的声明之上。URL 形状本身与其余浏览器可导入的契约面放在一起,位于 [`@deepseek-ai/dsh-host-apiproxy/api`](../../host/apiproxy/README.md),因此构造 URL 的浏览器半侧与解析 URL 的这一半共享同一个编码决定。cwd 与解析出的目标在比较前都要过 `realpath`,因此工作区内指向工作区外的符号链接会因其目标而被拒绝,而不是因其名字;穿越写法拒得更早,在解析期、任何文件系统调用之前。读取是流式的(没有请求会把文件缓冲起来),只应答 `GET`/`HEAD`,并带上 `nosniff` 与 `no-store`。所服务的内容类型表之外的扩展名一律按 `text/plain` 定型而非作为下载给出,因为工作区读取本就是一个“让我看看这个文件”的请求。 + +能执行脚本的文档——`.html`、`.htm`、`.xhtml`、`.svg`——还会额外带上 `Content-Security-Policy: sandbox allow-scripts allow-popups allow-modals allow-forms`。模型撰写的标记与 `/api` 同源提供,而 `/api/events.mux` 是一条可读的 `GET` 流,因此正是不透明源阻止了一个生成页面通过一次 `window.open` 读走会话事件流。代价由预览承担:其中无法使用 `localStorage`、cookie 与同源 `fetch`,而 `host.openPath` 仍是在 Host 机器上以完整能力打开同一文件的方式。这条前缀由同一道信任 fence 把守,因此配置了 `trustedHosts` 的部署提供工作区文件的范围,与它提供普通读取的范围完全一致。 + ## 无密钥 fixture 任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session;`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace;`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId,均具有足够的确定性,组装后的 Web 测试可以据此协调列表与帧的到达。fixture 内容搜索会保留面向生产环境的 `unicode61` 式大小写、变音符号和 token/短语行为,并返回以匹配位置为中心、最多包含 120 个 Unicode 码点的 snippet。 diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 9f091b26c5..f5fa3e34ea 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2362,6 +2362,11 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { }) return Promise.resolve({ accepted: true }) }, + + // The fixture has no filesystem behind its Sessions, so it names no + // directory for any of them; the /f route belongs to the node half, which + // a fixture page never reaches. + workspaceRootOf: () => Promise.resolve(undefined), } } diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index ed4af2d21f..4f9ce7d51b 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -1,11 +1,17 @@ -/** Host HTTP bridge for browser-client RPC. */ +/** Host HTTP bridge for browser-client RPC and workspace-file reads. */ import type { Context } from 'cordis' import z from 'schemastery' // Activates the httpServer Context merge used below. import type { WebRoute } from '@deepseek-ai/dsh-host-webserver' import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' +import { FILES_PATH } from '@deepseek-ai/dsh-host-apiproxy/api' +// The merge-free types subpath: pulling the session package's root into this +// client-registered program would merge the host `sessions` service over the +// browser runtime's own. +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { API_PATH } from './api-path.ts' import { bridge } from './http-bridge.ts' +import { handleWorkspaceFile } from './workspace-files.ts' import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts' export { API_PATH } from './api-path.ts' @@ -13,7 +19,7 @@ export { API_PATH } from './api-path.ts' /** Stable Cordis plugin name. */ export const name = 'client-connection' -/** Services required before mounting the route. */ +/** Services required before mounting the routes. */ export const inject = ['httpServer', 'apiProxy'] /** Plugin config: the deployment's non-loopback serving authorities. */ @@ -61,11 +67,11 @@ const PRIVILEGED_METHODS = new Set([ ]) /** - * Mounts the API gateway under the browser transport prefix. Every request on - * the prefix passes the browser-trust fence first (DNS-rebinding and - * cross-site defense — [api-request-trust](./api-request-trust.ts)); - * privileged methods additionally pass it with an empty trust list, which - * pins them to loopback. + * Mounts the API gateway and the workspace-file reads under the browser + * transport prefixes. Every request on either prefix passes the browser-trust + * fence first (DNS-rebinding and cross-site defense — + * [api-request-trust](./api-request-trust.ts)); privileged methods + * additionally pass it with an empty trust list, which pins them to loopback. * @param ctx - Host plugin context. * @param config - resolved plugin config (schema defaults applied). */ @@ -96,4 +102,28 @@ export function apply(ctx: Context, config?: ConnectionConfig): void { }, } ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route') + + // The gateway is the host's session authority: it answers where a Session's + // files live without this package reaching into the core services, which + // would merge their host-side Context declarations into the browser lane. + const cwdFor = (sessionId: string): Promise => + ctx.apiProxy.workspaceRootOf(sessionId as SessionId) + const filesRoute: WebRoute = { + kind: 'prefix', + path: FILES_PATH, + handler: async (req, res) => { + if (!isTrustedApiRequest(req, trustedHosts)) { + res.writeHead(403) + res.end('forbidden') + return + } + if (req.method !== 'GET' && req.method !== 'HEAD') { + res.writeHead(405) + res.end() + return + } + await handleWorkspaceFile(req, res, { cwdFor }) + }, + } + ctx.effect(() => ctx.httpServer.register(filesRoute), 'client-connection: /f route') } diff --git a/packages/client/connection/src/workspace-files.ts b/packages/client/connection/src/workspace-files.ts new file mode 100644 index 0000000000..9ad1b830a3 --- /dev/null +++ b/packages/client/connection/src/workspace-files.ts @@ -0,0 +1,169 @@ +/** + * The read half of the web transport: streams one file out of a session's + * workspace so the browser can open what the agent just produced. The RPC + * gateway carries structured session state; this route carries bytes, which a + * JSON-RPC envelope cannot stream and a `file://` link cannot reach from an + * http page. + * + * Confinement is the whole contract: a request names a session, the session + * names its cwd, and nothing outside that realpath is ever served. The caller + * owns the browser-trust fence ([api-request-trust](./api-request-trust.ts)) — + * this module is reached only by requests that already passed it. + */ + +import { createReadStream } from 'node:fs' +import { realpath, stat } from 'node:fs/promises' +import type { IncomingMessage, ServerResponse } from 'node:http' +import { extname, resolve, sep } from 'node:path' +import { pipeline } from 'node:stream/promises' +import { parseWorkspaceFilePath } from '@deepseek-ai/dsh-host-apiproxy/api' + +/** + * Content types served verbatim. Everything absent is `text/plain`, not + * `application/octet-stream`: a workspace read is a "show me what you made" + * gesture, and an unknown extension is far more often a source file to read + * than a binary to download. `nosniff` keeps that choice binding, so a + * mislabelled document can never be re-interpreted as HTML. + */ +const MIME: Record = { + '.html': 'text/html; charset=utf-8', + '.htm': 'text/html; charset=utf-8', + '.xhtml': 'application/xhtml+xml', + '.svg': 'image/svg+xml', + '.css': 'text/css; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.json': 'application/json', + '.pdf': 'application/pdf', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.avif': 'image/avif', + '.ico': 'image/x-icon', + '.mp4': 'video/mp4', + '.webm': 'video/webm', + '.mp3': 'audio/mpeg', + '.wav': 'audio/wav', + '.wasm': 'application/wasm', +} + +const DEFAULT_MIME = 'text/plain; charset=utf-8' + +/** Extensions whose top-level navigation can execute script, and so need the sandbox. */ +const SCRIPTABLE = new Set(['.html', '.htm', '.xhtml', '.svg']) + +/** + * Model-authored documents run in an opaque origin. Without it a generated page + * is same-origin with the RPC gateway, where `/api/events.mux` is a readable + * GET stream — one `window.open` away from every session's events. The cost is + * that `localStorage`, cookies, and same-origin `fetch` are unavailable inside + * a preview; the native-open path (`host.openPath`) remains the full-capability + * way to view a file. + */ +const SANDBOX_CSP = 'sandbox allow-scripts allow-popups allow-modals allow-forms' + +/** How the route learns which directory a session may serve from. */ +export interface WorkspaceFileDeps { + /** + * The session's absolute working directory. + * @param sessionId - the session named by the request path. + * @returns its cwd, or `undefined` when the id names no session this host serves. + */ + cwdFor: (sessionId: string) => Promise +} + +function fail(res: ServerResponse, status: number): void { + res.writeHead(status) + res.end() +} + +/** + * Resolve one request's segments against a session cwd, refusing anything that + * leaves it. Both sides go through `realpath`, so a symlink inside the + * workspace pointing out of it is refused by its resolved target rather than + * its name. A component swapped between this resolution and the open below + * would still be followed; closing that window needs privileges that already + * imply workspace write access, which is strictly stronger than reading a + * workspace file, so the check stops here. + */ +async function confine(cwd: string, segments: readonly string[]): Promise { + const root = await realpath(cwd) + const real = await realpath(resolve(root, ...segments)) + return real.startsWith(root + sep) ? real : undefined +} + +/** + * Serve one workspace-file request. The caller has already applied the + * browser-trust fence and rejected non-read methods. + * @param req - the request, read for its url and method only (no body). + * @param res - the response this function owns to completion. + * @param deps - the session-to-cwd lookup this host answers with. + */ +export async function handleWorkspaceFile( + req: IncomingMessage, + res: ServerResponse, + deps: WorkspaceFileDeps, +): Promise { + /* v8 ignore next -- `?? '/'` arm: node:http always sets url on server requests. */ + const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname + const target = parseWorkspaceFilePath(pathname) + if (target === undefined) { + fail(res, 404) + return + } + const cwd = await deps.cwdFor(target.sessionId) + if (cwd === undefined) { + fail(res, 404) + return + } + + let file: string | undefined + let size: number + try { + file = await confine(cwd, target.segments) + if (file === undefined) { + fail(res, 403) + return + } + const info = await stat(file) + // A directory read has no answer here: the route serves files, and listing + // is the directory-picker capability's job, behind its own fence. + if (!info.isFile()) { + fail(res, 404) + return + } + size = info.size + } catch { + // Missing, unreadable, or a path whose ancestor is not a directory: all + // report as absent, so a probe cannot distinguish them. + fail(res, 404) + return + } + + const ext = extname(file).toLowerCase() + res.writeHead(200, { + 'content-type': MIME[ext] ?? DEFAULT_MIME, + 'content-length': String(size), + 'content-disposition': 'inline', + 'x-content-type-options': 'nosniff', + // Workspace files change under the agent's hands; a cached preview would + // show the previous turn's output after the next edit. + 'cache-control': 'no-store', + ...SCRIPTABLE.has(ext) ? { 'content-security-policy': SANDBOX_CSP } : {}, + }) + if (req.method === 'HEAD') { + res.end() + return + } + try { + // pipeline (not pipe) so a client disconnect destroys the read stream: + // an abandoned preview must not leave a descriptor open. + await pipeline(createReadStream(file), res) + } catch { + // The status line is already out, so a mid-stream read failure or client + // disconnect can only end the response abruptly. + res.destroy() + } +} diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 08c65de2ba..0b2c58ab37 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -1,6 +1,9 @@ -/** Node half: registers the /api prefix route bridging to the api gateway. */ +/** Node half: registers the /api and /f prefix routes over the api gateway and the session workspaces. */ import { EventEmitter } from 'node:events' import { createServer, request as httpRequest } from 'node:http' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { Readable } from 'node:stream' import { Context } from 'cordis' import { describe, expect, it } from 'vitest' @@ -8,6 +11,7 @@ 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 { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver' +import { FILES_PATH } from '@deepseek-ai/dsh-host-apiproxy/api' import { API_PATH, apply, inject } from '../src/index.ts' /** Structural httpServer fake: the plugin only touches register(). */ @@ -45,31 +49,45 @@ function fakeResponse(): { response: ServerResponse; state: { status?: number; b return { response, state } } -async function mounted(config?: { trustedHosts?: string[] }): Promise<{ routes: WebRoute[]; dispose: () => Promise }> { +/** The gateway stub: only the session-directory authority the /f route reads. */ +function fakeApiProxy(workspaces: Record = {}): ApiProxy { + return { workspaceRootOf: async (id: string) => workspaces[id] } as unknown as ApiProxy +} + +async function mounted( + config?: { trustedHosts?: string[] }, + workspaces: Record = {}, +): Promise<{ routes: WebRoute[]; dispose: () => Promise }> { const ctx = new Context() const routes: WebRoute[] = [] ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService) - ctx.provide('apiProxy', {} as unknown as ApiProxy) + ctx.provide('apiProxy', fakeApiProxy(workspaces)) const fiber = ctx.plugin({ inject: [...inject], apply }, config) await fiber.await() return { routes, dispose: () => fiber.dispose() } } +/** The /f route is registered after /api; both are prefix routes on the same server. */ +function filesRoute(routes: WebRoute[]): WebRoute { + const route = routes.find(candidate => candidate.path === FILES_PATH) + if (route === undefined) throw new Error('the /f route was not registered') + return route +} + describe('connection node half', () => { it('fails the load on a trustedHosts entry that is not a bare authority', async () => { const routes: WebRoute[] = [] const ctx = new Context() ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService) - ctx.provide('apiProxy', {} as unknown as ApiProxy) + ctx.provide('apiProxy', fakeApiProxy()) 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) }) - it('registers the /api prefix route and removes it with the fiber', async () => { + it('registers both transport prefix routes and removes them with the fiber', async () => { const { routes, dispose } = await mounted() - expect(routes).toHaveLength(1) - expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH }) + expect(routes).toMatchObject([{ kind: 'prefix', path: API_PATH }, { kind: 'prefix', path: FILES_PATH }]) await dispose() expect(routes).toHaveLength(0) }) @@ -132,6 +150,52 @@ describe('connection node half', () => { }) }) +describe('connection node half: the /f workspace-file route', () => { + /** A workspace holding one file, torn down with the returned disposer. */ + async function workspace(): Promise<{ cwd: string; remove: () => Promise }> { + const cwd = await mkdtemp(join(tmpdir(), 'dsh-node-half-')) + await writeFile(join(cwd, 'index.html'), '

ok

') + return { cwd, remove: () => rm(cwd, { recursive: true, force: true }) } + } + + /** HEAD keeps the assertion on the route's decision, not on the byte stream. */ + function head(url: string, headers: Record = { host: '127.0.0.1:3080' }): IncomingMessage { + const request = fakeRequest(headers, url) + Object.assign(request, { method: 'HEAD' }) + return request + } + + it('applies the same browser-trust fence as /api, and refuses writes', async () => { + const { routes, dispose } = await mounted() + const untrusted = fakeResponse() + await filesRoute(routes).handler(head(`${FILES_PATH}/s-1/index.html`, { host: 'harness.example' }), untrusted.response) + expect(untrusted.state.status).toBe(403) + expect(untrusted.state.body).toBe('forbidden') + + const written = fakeResponse() + const post = fakeRequest({ host: '127.0.0.1:3080' }, `${FILES_PATH}/s-1/index.html`) + Object.assign(post, { method: 'POST' }) + await filesRoute(routes).handler(post, written.response) + expect(written.state.status).toBe(405) + await dispose() + }) + + it('confines reads to the directory the gateway names for that session', async () => { + const { cwd, remove } = await workspace() + const { routes, dispose } = await mounted(undefined, { 's-1': cwd }) + const served = fakeResponse() + await filesRoute(routes).handler(head(`${FILES_PATH}/s-1/index.html`), served.response) + expect(served.state.status).toBe(200) + // A session the gateway names no directory for has no workspace to confine + // against, so there is nothing to serve. + const unknown = fakeResponse() + await filesRoute(routes).handler(head(`${FILES_PATH}/s-absent/index.html`), unknown.response) + expect(unknown.state.status).toBe(404) + await dispose() + await remove() + }) +}) + describe('connection node half over a real HTTP server', () => { /** Serve the registered prefix route from a real server and return its port. */ async function serve(routes: WebRoute[]): Promise<{ port: number; close: () => Promise }> { diff --git a/packages/client/connection/tests/workspace-files.spec.ts b/packages/client/connection/tests/workspace-files.spec.ts new file mode 100644 index 0000000000..fb4288a8b2 --- /dev/null +++ b/packages/client/connection/tests/workspace-files.spec.ts @@ -0,0 +1,134 @@ +/** + * Workspace-file reads over a real HTTP server and a real temporary + * workspace: confinement, content typing, and the sandbox header are wire + * facts, so they are asserted against responses Node actually produced. + */ +import { createServer } from 'node:http' +import type { AddressInfo } from 'node:net' +import type { ServerResponse } from 'node:http' +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Writable } from 'node:stream' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { FILES_PATH } from '@deepseek-ai/dsh-host-apiproxy/api' +import { handleWorkspaceFile } from '../src/workspace-files.ts' + +const SESSION = 's-1' + +let workspace: string +let outside: string +let origin: string +let close: () => Promise + +beforeAll(async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-files-')) + workspace = join(root, 'workspace') + outside = join(root, 'outside') + await mkdir(join(workspace, 'out'), { recursive: true }) + await mkdir(outside, { recursive: true }) + await writeFile(join(workspace, 'index.html'), '

产物

') + await writeFile(join(workspace, 'notes.txt'), 'plain') + await writeFile(join(workspace, 'chart.svg'), '') + await writeFile(join(workspace, 'model.safetensors'), 'unknown extension') + await writeFile(join(workspace, 'out', 'page.html'), '

nested

') + await writeFile(join(outside, 'secret.html'), 'SECRET') + await symlink(join(outside, 'secret.html'), join(workspace, 'escape.html')) + + const server = createServer((req, res) => { + void handleWorkspaceFile(req, res, { + cwdFor: async sessionId => sessionId === SESSION ? workspace : undefined, + }) + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + origin = `http://127.0.0.1:${String((server.address() as AddressInfo).port)}` + close = () => new Promise((resolve, reject) => { + server.close((error) => { + if (error === undefined || error === null) resolve() + else reject(error) + }) + }) + return async () => { await rm(root, { recursive: true, force: true }) } +}) + +afterAll(async () => { await close() }) + +function get(path: string, init?: RequestInit): Promise { + return fetch(`${origin}${path}`, init) +} + +describe('workspace file reads', () => { + it('serves a produced document with the sandbox that keeps it off this origin', async () => { + const response = await get(`${FILES_PATH}/${SESSION}/index.html`) + expect(response.status).toBe(200) + expect(await response.text()).toBe('

产物

') + expect(response.headers.get('content-type')).toBe('text/html; charset=utf-8') + // The whole reason a model-authored page may be served from the RPC + // origin: an opaque origin cannot read /api/events.mux. + expect(response.headers.get('content-security-policy')).toContain('sandbox') + expect(response.headers.get('x-content-type-options')).toBe('nosniff') + expect(response.headers.get('cache-control')).toBe('no-store') + expect(response.headers.get('content-disposition')).toBe('inline') + }) + + it('sandboxes SVG too, and leaves non-scriptable types alone', async () => { + const svg = await get(`${FILES_PATH}/${SESSION}/chart.svg`) + expect(svg.headers.get('content-type')).toBe('image/svg+xml') + expect(svg.headers.get('content-security-policy')).toContain('sandbox') + const text = await get(`${FILES_PATH}/${SESSION}/notes.txt`) + expect(text.headers.get('content-type')).toBe('text/plain; charset=utf-8') + expect(text.headers.get('content-security-policy')).toBeNull() + }) + + it('shows an unknown extension as text rather than downloading it', async () => { + const response = await get(`${FILES_PATH}/${SESSION}/model.safetensors`) + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe('text/plain; charset=utf-8') + }) + + it('serves a nested path, so a document reaches its own siblings', async () => { + const response = await get(`${FILES_PATH}/${SESSION}/out/page.html`) + expect(response.status).toBe(200) + expect(await response.text()).toBe('

nested

') + }) + + it('answers HEAD with the length and no body', async () => { + const response = await get(`${FILES_PATH}/${SESSION}/notes.txt`, { method: 'HEAD' }) + expect(response.status).toBe(200) + expect(response.headers.get('content-length')).toBe('5') + expect(await response.text()).toBe('') + }) + + it('refuses a symlink whose target leaves the workspace', async () => { + const response = await get(`${FILES_PATH}/${SESSION}/escape.html`) + expect(response.status).toBe(403) + expect(await response.text()).not.toContain('SECRET') + }) + + it('reports missing files, directories, and unknown sessions as absent', async () => { + expect((await get(`${FILES_PATH}/${SESSION}/nope.html`)).status).toBe(404) + expect((await get(`${FILES_PATH}/${SESSION}/out`)).status).toBe(404) + // A path whose ancestor is a file, not a directory. + expect((await get(`${FILES_PATH}/${SESSION}/notes.txt/child`)).status).toBe(404) + expect((await get(`${FILES_PATH}/s-other/index.html`)).status).toBe(404) + expect((await get(`${FILES_PATH}/${SESSION}`)).status).toBe(404) + }) +}) + +describe('workspace file streaming failures', () => { + it('tears the response down instead of rejecting when the body cannot be written', async () => { + // A client that goes away mid-stream must not surface as a handler + // rejection: the webserver's last-resort guard would log it and try to + // answer 400 on a response whose status line is already out. + const sink = new Writable({ + write(_chunk, _encoding, callback) { callback(new Error('socket gone')) }, + }) + const response = Object.assign(sink, { writeHead: () => response }) as unknown as ServerResponse + await expect(handleWorkspaceFile( + { url: `${FILES_PATH}/${SESSION}/index.html`, method: 'GET', headers: {} } as never, + response, + { cwdFor: async () => workspace }, + )).resolves.toBeUndefined() + expect(sink.destroyed).toBe(true) + }) +}) diff --git a/packages/client/runtime/src/client/contract/workspaces.ts b/packages/client/runtime/src/client/contract/workspaces.ts index 3e64ef3717..dbc0f3b30f 100644 --- a/packages/client/runtime/src/client/contract/workspaces.ts +++ b/packages/client/runtime/src/client/contract/workspaces.ts @@ -56,6 +56,17 @@ export interface IWorkspaces { * @param path - absolute or host-resolvable path. */ openPath(path: string): Promise + /** + * URL serving one file out of a session's workspace, for a UI that opens a + * produced file in the browser instead of on the Host machine. + * @param sessionId - the session whose cwd anchors the path. + * @param cwd - that session's working directory, or `undefined` when unknown. + * @param path - the path a tool reported (absolute, or relative to `cwd`). + * @returns the origin-relative URL, or `undefined` when the path lies + * outside the workspace — which this transport never serves, leaving + * {@link IWorkspaces.openPath} as the only way to reach it. + */ + fileUrl(sessionId: SessionId, cwd: string | undefined, path: string): string | undefined /** * Rename a Workspace. * @param workspaceId - target workspace. diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index a0a76670f2..837a7daa03 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -5,6 +5,7 @@ import type { DirectoryListing, IApiClient, RpcError, SessionId, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-client-connection/client' +import { workspaceFileSegments, workspaceFileUrl } from '@deepseek-ai/dsh-host-apiproxy/api' import type { SnapshotStore } from '../contract/store.ts' import { createSnapshotStore } from '../contract/store.ts' import type { SessionsPort, SessionsPortList } from '../contract/sessions-port.ts' @@ -239,6 +240,19 @@ export class WorkspacesService implements IWorkspaces { } } + /** + * URL serving one file out of a session's workspace. + * @param sessionId - the session whose cwd anchors the path. + * @param cwd - that session's working directory, or `undefined` when unknown. + * @param path - the path a tool reported (absolute, or relative to `cwd`). + * @returns the origin-relative URL, or `undefined` for a path outside the workspace. + */ + fileUrl(sessionId: SessionId, cwd: string | undefined, path: string): string | undefined { + const segments = workspaceFileSegments(cwd, path) + if (segments === undefined) return undefined + return workspaceFileUrl(sessionId, segments) + } + /** * Rename a Workspace. * @param workspaceId - target workspace. diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index 4323d7ffce..3d9cef547f 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -276,6 +276,21 @@ describe('WorkspacesService', () => { await expect(workspaces.openPath('/missing')).rejects.toThrow(/path open failed/) }) + it('addresses a workspace file by URL, and only inside the workspace', async () => { + const ctx = new Context() + const api = new FakeApiClient() + const sessions = new SessionsService(ctx, api) + const workspaces = new WorkspacesService(ctx, api, sessions) + const session = 's-1' as SessionId + // The URL is derived, not fetched: no wire call answers a link. + expect(workspaces.fileUrl(session, '/w/alpha', '/w/alpha/out/a b.html')).toBe('/f/s-1/out/a%20b.html') + expect(workspaces.fileUrl(session, '/w/alpha', 'out/index.html')).toBe('/f/s-1/out/index.html') + // Outside the workspace there is nothing this transport may serve, which + // is the signal a caller falls back to openPath on. + expect(workspaces.fileUrl(session, '/w/alpha', '/etc/hosts')).toBeUndefined() + expect(api.calls).toHaveLength(0) + }) + it('deletes a Workspace or preserves it when the Host rejects deletion', async () => { const ctx = new Context() const api = new FakeApiClient() diff --git a/packages/client/test-runtime/src/workspaces.ts b/packages/client/test-runtime/src/workspaces.ts index 7e626a3660..01e7db4c3d 100644 --- a/packages/client/test-runtime/src/workspaces.ts +++ b/packages/client/test-runtime/src/workspaces.ts @@ -1,5 +1,6 @@ /** Test-owned workspaces face: the renderer standard-kit observable plus recorded actions. */ import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { workspaceFileSegments, workspaceFileUrl } from '@deepseek-ai/dsh-host-apiproxy/api' import type { DirectoryListing, IWorkspaces, SessionId, SnapshotStore, WorkspaceId, WorkspaceListState, WorkspaceView, } from '@deepseek-ai/dsh-client-runtime/client' @@ -98,6 +99,22 @@ export class TestWorkspaces implements IWorkspaces { await (this.stubs.get('openPath')?.(path) as Promise | undefined) } + /** + * Workspace-file URL (recorded). Runs the production path derivation so a + * feature test sees the real in/outside-workspace split; stub to force either. + * @param sessionId - the session whose cwd anchors the path. + * @param cwd - that session's working directory. + * @param path - the path a tool reported. + * @returns the origin-relative URL, or undefined outside the workspace. + */ + fileUrl(sessionId: SessionId, cwd: string | undefined, path: string): string | undefined { + this.calls.push({ method: 'fileUrl', args: [sessionId, cwd, path] }) + const stub = this.stubs.get('fileUrl') + if (stub !== undefined) return stub(sessionId, cwd, path) as string | undefined + const segments = workspaceFileSegments(cwd, path) + return segments === undefined ? undefined : workspaceFileUrl(sessionId, segments) + } + /** * Directory picker (recorded). The default cancels (null); stub to select. * @returns the picked path, or null. diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx index 3675671f26..a9c4b0c9ca 100644 --- a/packages/client/test-runtime/tests/runtime.spec.tsx +++ b/packages/client/test-runtime/tests/runtime.spec.tsx @@ -549,6 +549,10 @@ describe('workspaces action face', () => { expect(renamed.title).toBe('Renamed') await ws.delete('w1' as WorkspaceId) await ws.openPath('/proj/file.ts') + // fileUrl runs the production derivation, so a feature test sees the same + // inside/outside-workspace split the browser half decides on. + expect(ws.fileUrl('s1' as SessionId, '/proj', 'out/a.html')).toBe('/f/s1/out/a.html') + expect(ws.fileUrl('s1' as SessionId, '/proj', '/etc/hosts')).toBeUndefined() const moved = await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId, 's2' as SessionId) expect(moved.sessionIds).toEqual(['s1']) // Default archive mirrors the production effect: the id joins the list @@ -556,13 +560,15 @@ describe('workspaces action face', () => { await ws.archiveSession('s1' as SessionId) expect(ws.list.getSnapshot().archivedSessionIds).toEqual(['s1']) expect(ws.calls.map(c => c.method)).toEqual( - ['create', 'create', 'pickDirectory', 'rename', 'delete', 'openPath', 'insertSessionBefore', 'archiveSession']) + ['create', 'create', 'pickDirectory', 'rename', 'delete', 'openPath', 'fileUrl', 'fileUrl', + 'insertSessionBefore', 'archiveSession']) ws.stub('create', () => Promise.resolve({ workspaceId: 'ws-x', title: 'X', path: '/x', sessionIds: [] } as never)) ws.stub('pickDirectory', () => Promise.resolve('/picked')) ws.stub('rename', () => Promise.resolve({ workspaceId: 'w1', title: 'S', path: '/s', sessionIds: [] } as never)) ws.stub('delete', () => Promise.resolve()) ws.stub('openPath', () => Promise.resolve()) + ws.stub('fileUrl', () => '/f/forced/a.html') ws.stub('insertSessionBefore', () => Promise.resolve({ workspaceId: 'w1', title: '', path: '', sessionIds: [] } as never)) ws.stub('archiveSession', () => Promise.resolve()) expect((await ws.create({ name: 'y' })).title).toBe('X') @@ -570,6 +576,7 @@ describe('workspaces action face', () => { expect((await ws.rename('w1' as WorkspaceId, 'z')).title).toBe('S') await ws.delete('w1' as WorkspaceId) await ws.openPath('/other') + expect(ws.fileUrl('s1' as SessionId, '/proj', '/etc/hosts')).toBe('/f/forced/a.html') expect((await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId)).sessionIds).toEqual([]) // The stub replaces the default set mutation: the set stays as-is. await ws.archiveSession('s2' as SessionId) diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index c67431e409..71f05267b3 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -275,6 +275,15 @@ export function apply(ctx: Context): void { }, openFile: (path) => { const cwd = sessions.list.getSnapshot().byId[sessionId]?.cwd + // A file inside the workspace opens in a new tab, so a browser that + // is not on the Host machine can still see what the agent produced. + // Anything outside it has no served URL and falls back to the Host's + // own opener, which is loopback-only by the /api trust fence. + const url = workspaces.fileUrl(sessionId, cwd, path) + if (url !== undefined) { + window.open(url, '_blank', 'noopener,noreferrer') + return + } void workspaces.openPath(resolveToolPath(cwd, path)).catch(() => { // Host/OS open failures stay silent in the chat row; the native // app surfaces its own error dialog when the path is unusable. diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index cfbb0fdcbe..6427f6750c 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -218,13 +218,22 @@ describe('conversation slot inject surface', () => { await b.runtime.dispose() }) - it('openFile (chat view face) resolves against session cwd and calls workspaces.openPath', async () => { + it('openFile (chat view face) opens a workspace file in a tab and falls back to the host opener outside it', async () => { const b = await bench() + const open = vi.spyOn(window, 'open').mockReturnValue(null) const { injected } = b.chatViewSurface(ROOT) + // Inside the session cwd: served by this origin, so a browser anywhere on + // the network sees the file the agent produced. injected.openFile('src/a.ts') + expect(open).toHaveBeenCalledWith(`/f/${ROOT}/src/a.ts`, '_blank', 'noopener,noreferrer') + expect(b.runtime.workspaces.calls.some(c => c.method === 'openPath')).toBe(false) + // Outside it there is no served URL, so the Host's own opener answers — + // resolved against the session cwd exactly as before. + injected.openFile('/etc/hosts') await vi.waitFor(() => { - expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['/proj/src/a.ts'] }) + expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['/etc/hosts'] }) }) + open.mockRestore() await b.runtime.dispose() }) diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index bafc6fe709..51e31c9750 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -134,6 +134,7 @@ async function bench(snapshot: ConversationSnapshot) { startSession: vi.fn(), sendSession: vi.fn(), openPath: vi.fn(async () => {}), + fileUrl: vi.fn((_sessionId: unknown, _cwd: string | undefined, path: string) => `/f/s-1/${path}`), } ctx.provide('workspaces', workspaces) ctx.provide('layout', layout) @@ -243,12 +244,14 @@ describe('run_code sub-calls through the real chat machinery', () => { subCall(12, parent, 2, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'), ]]]) const b = await bench(snapshotWith([codeResult(10, parent)], dispatches)) + const open = vi.spyOn(window, 'open').mockReturnValue(null) const view = mountApp(b.slots) view.getByText('notes/demo.txt').click() expect(b.layout.openDetails).not.toHaveBeenCalled() await vi.waitFor(() => { - expect(b.workspaces.openPath).toHaveBeenCalledWith('notes/demo.txt') + expect(open).toHaveBeenCalledWith('/f/s-1/notes/demo.txt', '_blank', 'noopener,noreferrer') }) + open.mockRestore() view.getByText('List notes').click() expect(b.layout.openDetails).not.toHaveBeenCalled() }) diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index eb48677d4f..6cb46e7ea0 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -119,14 +119,16 @@ 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 → the served workspace URL', async () => { const b = await bench([toolResult(3, 'c1', 'read', '{"path":"src/a.ts"}')]) + const open = vi.spyOn(window, 'open').mockReturnValue(null) 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(open).toHaveBeenCalledWith(expect.stringContaining('/src/a.ts'), '_blank', 'noopener,noreferrer') }) + open.mockRestore() await b.runtime.dispose() }) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 27a1434e60..b96bf528a7 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: 3c5a83a468b0cf9e596b8b13fafe40c409576fc5 -README.zh.md: f8533564575bf6b716f3fa7241ce47b8d4dd435f +README.md: ee8e758a68f6efa3e363a36fcc9e8444e589ea40 +README.zh.md: 4ec3817e65543d6e248be9d902d0b74674f56e5a diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 3c5a83a468..ee8e758a68 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -36,6 +36,8 @@ The `command.*` and `skill.*` domains expose the host command registry and skill The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, and the section's `revision`. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads included (`settings.describe`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. +Two members of `ApiProxy` are deliberately not wire methods. `respond` is the client-response entry (four-quadrant model), and `workspaceRootOf` answers where a Session's files live for an in-process reader — a live agent's header first, then the persistence store, never a resume. It has no wire face: a browser learns a Session's cwd from `sessions.view`, and reaches a file through the web transport's own `/f` route, never by asking for a host path. That route's URL shape (`api/files.ts`: `FILES_PATH`, `workspaceFileSegments`, `workspaceFileUrl`, `parseWorkspaceFilePath`) lives here with the other browser-importable contract surfaces, so the browser half that builds a `/f` URL and the serving half that parses one cannot drift apart; the route itself belongs to [`dsh-client-connection`](../../client/connection/README.md). + ## Carrier layer (`/client` + root) `AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` is the isomorphic point: the full wire serialization/validation path with no network, used by `dsh -p` headless. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index f853356457..4ec3817e65 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -36,6 +36,8 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表,以及该分节的 `revision`。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;过期的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取:`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 +`ApiProxy` 上有两个成员刻意不是协议方法。`respond` 是客户端响应入口(四象限模型),`workspaceRootOf` 则为进程内读取方回答某个 Session 的文件位于何处——先看活跃 agent 的 header,再看持久化存储,绝不恢复会话。它没有协议面:浏览器从 `sessions.view` 得知 Session 的 cwd,并经由 web 传输自己的 `/f` 路由抵达文件,而不是靠索要一条宿主路径。该路由的 URL 形状(`api/files.ts`:`FILES_PATH`、`workspaceFileSegments`、`workspaceFileUrl`、`parseWorkspaceFilePath`)与其余浏览器可导入的契约面一同放在这里,因此构造 `/f` URL 的浏览器半侧与解析它的服务半侧不会彼此漂移;路由本身则属于 [`dsh-client-connection`](../../client/connection/README.md)。 + ## 载体层(`/client` + 根路径) `AbstractApiClient` 持有全部协议不变量:签发 rpcId、包装/解包信封、Zod 解析、SSE 帧解码、一元请求超时,以及按微任务批处理的信封观测(`subscribeEnvelopes`);平台子类只提供 `doFetch` 传输环节。`InProcessApiClient` 以 `toFetchHandler(api)` 为基础,是同构接点:它运行完整的协议序列化与校验路径而不经过网络,供 `dsh -p` headless 模式使用。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 4e506ed262..3c20785657 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -2290,5 +2290,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro pending.resolve(payload.answer) return Promise.resolve({ accepted: true }) }, + + async workspaceRootOf(sessionId: SessionId): Promise { + // A live agent answers from its own header; otherwise the store answers, + // deliberately without resuming — reading a session's directory must not + // pull an agent up the way the cold RPC path does. + const live = ctx.agents.get(sessionId) + if (live !== undefined) return live.session.header.cwd + const persistence = ctx.get('sessionPersistence') + if (persistence === undefined) return undefined + return (await persistence.list()).find(meta => meta.id === sessionId)?.cwd + }, } } diff --git a/packages/host/apiproxy/src/api/files.ts b/packages/host/apiproxy/src/api/files.ts new file mode 100644 index 0000000000..b4ba01f29b --- /dev/null +++ b/packages/host/apiproxy/src/api/files.ts @@ -0,0 +1,98 @@ +/** + * The `/f` workspace-file URL shape: the contract half of the web transport + * that carries bytes rather than RPC. The browser turns a tool's file path + * into a URL, the serving side turns that URL back into the segments below a + * session's cwd, and both read this one encoding decision so neither can drift + * into serving a path the other never meant. Pure string work with no Node and + * no DOM, like the rest of `api/` — the browser bundle inlines it. + * @module @deepseek-ai/dsh-host-apiproxy/api/files + */ + +/** + * Route prefix owning every workspace-file read (`/f//`). + * The path carries the segments verbatim rather than a query parameter so a + * served document's relative references (`./logo.png`) resolve to their + * siblings in the same workspace directory. + */ +export const FILES_PATH = '/f' + +/** One parsed workspace-file request: whose workspace, and where inside it. */ +export interface WorkspaceFileTarget { + /** The owning session, still an opaque string — the caller resolves it to a cwd. */ + sessionId: string + /** Decoded path segments below that session's cwd; never empty, never `.` or `..`. */ + segments: string[] +} + +/** A segment that survived decoding but would re-enter path resolution as more than one name. */ +function isPlainSegment(segment: string): boolean { + return segment !== '' && segment !== '.' && segment !== '..' + && !segment.includes('/') && !segment.includes('\\') && !segment.includes('\0') +} + +function decode(raw: string): string | undefined { + try { + return decodeURIComponent(raw) + } catch { + // A malformed %-escape is a request we cannot interpret, not a miss. + return undefined + } +} + +/** + * Express one tool-reported file path as segments below the session cwd. + * @param cwd - the session's working directory, or `undefined` when unknown. + * @param path - the path the tool reported (absolute, or relative to `cwd`). + * @returns the segments below `cwd`, or `undefined` when the path names + * something outside the workspace (which this route never serves) or resolves + * to the workspace directory itself. + */ +export function workspaceFileSegments(cwd: string | undefined, path: string): string[] | undefined { + const slashed = path.replace(/\\/g, '/') + const absolute = /^\/|^[A-Za-z]:\//.test(slashed) + let relative: string + if (absolute) { + if (cwd === undefined || cwd === '') return undefined + const root = cwd.replace(/\\/g, '/').replace(/\/+$/, '') + if (!slashed.startsWith(`${root}/`)) return undefined + relative = slashed.slice(root.length + 1) + } else { + relative = slashed + } + const segments = relative.split('/').filter(segment => segment !== '' && segment !== '.') + if (segments.length === 0 || segments.some(segment => !isPlainSegment(segment))) return undefined + return segments +} + +/** + * Build the origin-relative URL serving one workspace file. + * @param sessionId - the session whose cwd anchors the path. + * @param segments - segments below that cwd, as {@link workspaceFileSegments} returns them. + * @returns the `/f/…` URL, resolved by the browser against the serving origin. + */ +export function workspaceFileUrl(sessionId: string, segments: readonly string[]): string { + const encoded = segments.map(segment => encodeURIComponent(segment)).join('/') + return `${FILES_PATH}/${encodeURIComponent(sessionId)}/${encoded}` +} + +/** + * Parse a request pathname back into the session and segments it names. + * @param pathname - the request's raw (still percent-encoded) pathname. + * @returns the target, or `undefined` when the pathname is not a well-formed + * workspace-file read — including every traversal shape, which is refused here + * before any filesystem call rather than being resolved and then judged. + */ +export function parseWorkspaceFilePath(pathname: string): WorkspaceFileTarget | undefined { + if (!pathname.startsWith(`${FILES_PATH}/`)) return undefined + const [rawSession, ...rawSegments] = pathname.slice(FILES_PATH.length + 1).split('/') + if (rawSession === undefined || rawSegments.length === 0) return undefined + const sessionId = decode(rawSession) + if (sessionId === undefined || sessionId === '') return undefined + const segments: string[] = [] + for (const raw of rawSegments) { + const segment = decode(raw) + if (segment === undefined || !isPlainSegment(segment)) return undefined + segments.push(segment) + } + return { sessionId, segments } +} diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index 87aa1036bf..227e26264e 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -15,6 +15,9 @@ import type { SettingsApi } from './settings.ts' import type { CredentialsApi } from './credentials.ts' import type { LlmApi } from './llm.ts' import type { ClientResponse, RpcReceipt } from './rpc.ts' +// The merge-free types subpath: api/ is imported from the browser lane, where +// the host session service must not merge over the client runtime's own. +import type { SessionId } from '@deepseek-ai/dsh-session/types' /** Root interface of the unified API surface. New client-request domain = one new file pair + one field here + one map row. */ export interface ApiProxy { @@ -30,6 +33,17 @@ export interface ApiProxy { llm: LlmApi /** Response entry for server-requests (client-response, echoing their rpcId); not a domain method (four-quadrant model). */ respond(message: ClientResponse): Promise + /** + * The directory a Session's files may be read from — the same `cwd` the + * session summaries carry, in non-envelope form for an in-process reader. + * Not a domain method: it has no wire face, because a browser learns a + * Session's cwd from `sessions.view` and a file it may read from the web + * transport's own `/f` route, never by asking for a host path. + * @param sessionId - the Session to locate. + * @returns its absolute working directory, or `undefined` when this host + * serves no such Session. Resolving one never resumes an agent. + */ + workspaceRootOf(sessionId: SessionId): Promise } // ---- Domain interfaces and payload entities ---- @@ -48,6 +62,10 @@ export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSe export type { CredentialsApi, CredentialView } from './credentials.ts' export type { ConfigurableProviderView, LlmApi } from './llm.ts' export type { ApprovalResponsePayload } from './approvals.ts' + +// ---- Workspace-file URL shape (the transport's byte-carrying half) ---- +export { FILES_PATH, workspaceFileSegments, workspaceFileUrl, parseWorkspaceFilePath } from './files.ts' +export type { WorkspaceFileTarget } from './files.ts' export type { QuestionResponsePayload } from './questions.ts' // ---- Message layer: narrow forms (domain-signature view) ---- diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index 339b1e777d..f6dec19420 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -64,6 +64,7 @@ export class ApiProxyService extends Service implements ApiProxy { readonly llm: ApiProxy['llm'] readonly events: ApiProxy['events'] readonly respond: ApiProxy['respond'] + readonly workspaceRootOf: ApiProxy['workspaceRootOf'] constructor(ctx: Context, config: Config) { super(ctx, 'apiProxy') @@ -87,6 +88,7 @@ export class ApiProxyService extends Service implements ApiProxy { // createApiProxy returns closures (no `this` capture); bind only satisfies // the unbound-method lint without changing behavior. this.respond = api.respond.bind(api) + this.workspaceRootOf = api.workspaceRootOf.bind(api) } } diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index da05a4cd9b..cc30e5dee2 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -62,7 +62,11 @@ function stubAgent(session: Session): Agent { async function harness( workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))), picker: DirectoryPickerCapability = { kind: 'native', pick: async () => null }, - extras: { openPath?: (path: string, signal: AbortSignal) => Promise } = {}, + extras: { + openPath?: (path: string, signal: AbortSignal) => Promise + /** Store contents behind the gateway, or 'absent' for a composition with no persistence at all. */ + persisted?: { id: SessionId; cwd?: string }[] | 'absent' + } = {}, ) { const ctx = new Context() await ctx.plugin(SessionStore) @@ -73,7 +77,10 @@ async function harness( const storageDomain = new DomainFacility(ctx, { backend: 'memory', routes: {} }) ctx.storage.mount('domain', storageDomain) ctx.provide('storageDomain', storageDomain) - ctx.provide('sessionPersistence', { list: () => Promise.resolve([]) } as never) + if (extras.persisted !== 'absent') { + const persisted = extras.persisted ?? [] + ctx.provide('sessionPersistence', { list: () => Promise.resolve(persisted) } as never) + } await ctx.plugin(WorkspaceRegistry) const factory: AgentFactory = { @@ -244,6 +251,27 @@ describe('host.openPath', () => { }) }) +describe('workspaceRootOf', () => { + it('answers from the live agent, then the store, and names nothing for an unknown session', async () => { + const { api, workspaceRoot } = await harness(undefined, undefined, { + persisted: [{ id: 's-cold' as SessionId, cwd: '/w/cold' }], + }) + const created = await api.sessions.create(request({ cwd: workspaceRoot })) + const sessionId = (created.result as { ok: true; value: { sessionId: SessionId } }).value.sessionId + // Live: the agent's own header, no store read involved. + await expect(api.workspaceRootOf(sessionId)).resolves.toBe(workspaceRoot) + // Not live: the store answers, and the lookup never resumes an agent — + // this harness's factory throws on resume, so a resuming lookup would fail. + await expect(api.workspaceRootOf('s-cold' as SessionId)).resolves.toBe('/w/cold') + await expect(api.workspaceRootOf('s-absent' as SessionId)).resolves.toBeUndefined() + }) + + it('names nothing at all when the host keeps no session store', async () => { + const { api } = await harness(undefined, undefined, { persisted: 'absent' }) + await expect(api.workspaceRootOf('s-any' as SessionId)).resolves.toBeUndefined() + }) +}) + describe('workspace.create', () => { it('serializes concurrent names and rejects the duplicate', async () => { const { api, workspaceRoot } = await harness() diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 6307dfe8f9..2299949890 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -108,6 +108,8 @@ function scriptedApi(overrides: { }, events: { mux: () => empty(), host: () => empty(), ...overrides.events }, respond: overrides.respond ?? (() => Promise.resolve({ accepted: false as const, reason: 'not-pending' as const })), + // No wire face, so the handler map never reaches it. + workspaceRootOf: () => Promise.resolve(undefined), } } diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index ef111afe12..dac49a1234 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -233,6 +233,8 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra async respond(message: ClientResponse): Promise { return message.rpcId === 'known' ? { accepted: true } : { accepted: false, reason: 'not-pending' } }, + // No wire face, so the carrier never reaches it. + workspaceRootOf: () => Promise.resolve(undefined), } } diff --git a/packages/host/apiproxy/tests/files-path.spec.ts b/packages/host/apiproxy/tests/files-path.spec.ts new file mode 100644 index 0000000000..df309a4783 --- /dev/null +++ b/packages/host/apiproxy/tests/files-path.spec.ts @@ -0,0 +1,74 @@ +/** The /f URL shape: one encoding decision, asserted from both ends. */ +import { describe, expect, it } from 'vitest' +import { + FILES_PATH, parseWorkspaceFilePath, workspaceFileSegments, workspaceFileUrl, +} from '../src/api/files.ts' + +describe('workspaceFileSegments', () => { + it('keeps a relative path as its own segments', () => { + expect(workspaceFileSegments('/w', 'out/index.html')).toEqual(['out', 'index.html']) + expect(workspaceFileSegments(undefined, 'index.html')).toEqual(['index.html']) + expect(workspaceFileSegments('/w', './a/./b.txt')).toEqual(['a', 'b.txt']) + }) + + it('strips the cwd prefix from an absolute path inside the workspace', () => { + expect(workspaceFileSegments('/w', '/w/a/b.html')).toEqual(['a', 'b.html']) + // A trailing separator on the cwd must not shift the split. + expect(workspaceFileSegments('/w/', '/w/a.html')).toEqual(['a.html']) + }) + + it('reads Windows paths on either separator', () => { + expect(workspaceFileSegments('C:\\w', 'C:\\w\\a\\b.html')).toEqual(['a', 'b.html']) + expect(workspaceFileSegments('C:/w', 'C:\\w\\a.html')).toEqual(['a.html']) + }) + + it('refuses everything the route would not serve', () => { + // Absolute, but not under this workspace. + expect(workspaceFileSegments('/w', '/etc/hosts')).toBeUndefined() + // A sibling directory sharing the cwd's name prefix is not inside it. + expect(workspaceFileSegments('/w', '/workspace-other/a')).toBeUndefined() + // Absolute with no cwd to anchor against. + expect(workspaceFileSegments(undefined, '/w/a.html')).toBeUndefined() + expect(workspaceFileSegments('', '/w/a.html')).toBeUndefined() + // Traversal, in either spelling. + expect(workspaceFileSegments('/w', '../secret')).toBeUndefined() + expect(workspaceFileSegments('/w', 'a/../../secret')).toBeUndefined() + // The workspace directory itself is not a file. + expect(workspaceFileSegments('/w', '/w')).toBeUndefined() + expect(workspaceFileSegments('/w', '.')).toBeUndefined() + }) +}) + +describe('workspaceFileUrl', () => { + it('percent-encodes each segment but keeps the separators structural', () => { + expect(workspaceFileUrl('s-1', ['out', 'a b.html'])).toBe(`${FILES_PATH}/s-1/out/a%20b.html`) + expect(workspaceFileUrl('s/1', ['a#b.html'])).toBe(`${FILES_PATH}/s%2F1/a%23b.html`) + }) +}) + +describe('parseWorkspaceFilePath', () => { + it('round-trips what the browser half builds', () => { + const url = workspaceFileUrl('s-1', ['out', 'a b.html']) + expect(parseWorkspaceFilePath(url)).toEqual({ sessionId: 's-1', segments: ['out', 'a b.html'] }) + }) + + it('refuses malformed, prefix-foreign, and traversal pathnames', () => { + expect(parseWorkspaceFilePath('/api/session.list')).toBeUndefined() + expect(parseWorkspaceFilePath(FILES_PATH)).toBeUndefined() + // Session named but no file below it. + expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1`)).toBeUndefined() + expect(parseWorkspaceFilePath(`${FILES_PATH}//a.html`)).toBeUndefined() + // Traversal is refused at parse time, before any filesystem call. + expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/../etc/hosts`)).toBeUndefined() + expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/a/./b`)).toBeUndefined() + expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/a//b`)).toBeUndefined() + // A separator smuggled through percent-encoding stays one segment's problem. + expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/a%2F..%2Fb`)).toBeUndefined() + expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/a%5Cb`)).toBeUndefined() + expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/a%00b`)).toBeUndefined() + // Malformed percent-escapes are uninterpretable, not a miss to resolve. + expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/a%zz`)).toBeUndefined() + expect(parseWorkspaceFilePath(`${FILES_PATH}/%zz/a.html`)).toBeUndefined() + expect(parseWorkspaceFilePath(`${FILES_PATH}//`)).toBeUndefined() + }) +}) diff --git a/tsconfig.host.json b/tsconfig.host.json index bae800f3ff..9de5b51da0 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -38,6 +38,7 @@ "apps/web/tests/access-confirmation.e2e.ts", "apps/web/tests/shipped-composition.e2e.ts", "apps/web/tests/startup-auto-selection.e2e.ts", + "apps/web/tests/workspace-file-open.e2e.ts", "apps/cli/tests/**/*.ts", "examples/*/src/**/*.ts", "examples/*/start.ts", From 35e9122a658659154691bddbe1d5aa150716ddcb Mon Sep 17 00:00:00 2001 From: ZiyaZhang Date: Fri, 31 Jul 2026 22:13:34 -0700 Subject: [PATCH 005/104] feat(web): list a turn's produced files under its closing message The paths come from the mutation tools' follow-along locations, not from the closing prose, so a turn's output is listed whether or not the model named it. Each chip opens through the same openFile the tool rows use. Reads contribute nothing (looking at a file does not produce it), a failed mutation contributes nothing, a file touched twice is one entry, and the row shows six with an explicit remainder rather than burying the answer. --- .../src/client/chat/AssistantMarkdown.tsx | 11 +++- .../src/client/chat/ChatView.tsx | 7 ++- .../src/client/chat/Deliverables.module.css | 44 ++++++++++++++ .../src/client/chat/Deliverables.tsx | 54 ++++++++++++++++++ .../src/client/chat/chat-flow.ts | 37 ++++++++++++ .../ui-conversation/src/client/locales.ts | 6 ++ .../ui-conversation/tests/chat-view.spec.tsx | 57 ++++++++++++++++++- 7 files changed, 213 insertions(+), 3 deletions(-) create mode 100644 packages/client/ui-conversation/src/client/chat/Deliverables.module.css create mode 100644 packages/client/ui-conversation/src/client/chat/Deliverables.tsx diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index 387a7fd82a..024a67a843 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -14,6 +14,7 @@ import { IconThinkOutline14, JsonBlock, MarkdownText, } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' +import { Deliverables } from './Deliverables.tsx' import { MessageIconActions } from './MessageIconActions.tsx' import { ToolRow } from './ToolRow.tsx' import css from './AssistantMarkdown.module.css' @@ -30,6 +31,11 @@ export interface AssistantMarkdownProps { seq?: number | undefined /** Fork the session through the turn containing this finalized message. */ onFork?: ((seq: number) => void) | undefined + /** Files the closing turn produced, listed under the body; omitted for a + * mid-turn assistant and for a turn that wrote nothing. */ + produced?: readonly string[] | undefined + /** Opens one produced file; omitted wherever `produced` is. */ + openFile?: ((path: string) => void) | undefined /** The owning view's locale seat, passed down as a plain prop. */ t: ChatViewSlotProps['t'] } @@ -69,7 +75,7 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass } export const AssistantMarkdown = memo(function AssistantMarkdown({ - blocks, streaming, interrupted, time, seq, onFork, t, + blocks, streaming, interrupted, time, seq, onFork, produced, openFile, t, }: AssistantMarkdownProps) { // Stable per locale revision (t identity changes on switch): a fresh object // per render would rebuild MarkdownText's component table every chunk. @@ -107,6 +113,9 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ })} {interrupted && {t('message.stopped')}} + {showActions && produced !== undefined && openFile !== undefined && ( + + )} {showActions && ( assistantActionsSeqs(nodes), [nodes]) + // Produced files per closing assistant: derived from the mutation tools' + // locations, so a turn's output is listed whether or not the model named it. + const produced = useMemo(() => turnDeliverables(nodes), [nodes]) const listRef = useRef(null) const atBottomRef = useRef(true) @@ -402,6 +405,8 @@ export function ChatView({ time={actionSeqs.has(node.seq) ? node.time : undefined} seq={node.seq} onFork={forkAt} + produced={produced.get(node.seq)} + openFile={openFile} t={t} /> ) diff --git a/packages/client/ui-conversation/src/client/chat/Deliverables.module.css b/packages/client/ui-conversation/src/client/chat/Deliverables.module.css new file mode 100644 index 0000000000..2077de48ac --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/Deliverables.module.css @@ -0,0 +1,44 @@ +/* Turn-tail produced-files row: a quiet label followed by wrapping file chips. + Sits between the assistant body and its IconActions footer, so it reads as + part of the answer rather than as another tool row. */ + +.root { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-top: 16px; + font-size: 13px; + line-height: 22px; +} + +.label { + color: var(--dsw-alias-label-tertiary); +} + +/* One produced file. A link by behavior (it opens the file), a chip by shape: + full paths are long and several may wrap onto one row. */ +.file { + max-width: 320px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin: 0; + padding: 0 8px; + border: none; + border-radius: 6px; + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-secondary); + font: inherit; + cursor: pointer; +} + +.file:hover { + color: var(--dsw-alias-label-primary); + text-decoration: underline; +} + +/* Overflow count: the row never silently drops files it did not show. */ +.more { + color: var(--dsw-alias-label-tertiary); +} diff --git a/packages/client/ui-conversation/src/client/chat/Deliverables.tsx b/packages/client/ui-conversation/src/client/chat/Deliverables.tsx new file mode 100644 index 0000000000..0a0160b486 --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/Deliverables.tsx @@ -0,0 +1,54 @@ +// Deliverables: the produced-file row a finished turn ends with. The paths come +// from the mutation tools' follow-along locations (see turnDeliverables), never +// from the closing prose, so the answer carries its own output whether or not +// the model remembered to name it. Clicking one goes through the same openFile +// the tool rows use — in the browser that is a new tab served from the session +// workspace, and outside it the Host's own opener. + +import type { ChatViewSlotProps } from '../contract/slots.ts' +import css from './Deliverables.module.css' + +/** Files past this stay counted but unlisted: a refactor turn must not bury the answer. */ +const SHOWN = 6 + +/** Trailing path segment, the part that identifies the file at a glance. */ +function basename(path: string): string { + const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + return at === -1 ? path : path.slice(at + 1) +} + +/** + * Render one turn's produced files as openable chips. + * @param props - the turn's paths (tool order, already deduped), the chat + * view's file opener, and the owning view's locale seat. + * @returns The row, or `null` when the turn produced nothing. + */ +export function Deliverables({ paths, openFile, t }: { + paths: readonly string[] + openFile: (path: string) => void + t: ChatViewSlotProps['t'] +}) { + if (paths.length === 0) return null + const shown = paths.slice(0, SHOWN) + const hidden = paths.length - shown.length + return ( +
+ {t('produced.label')} + {shown.map(path => ( + + ))} + {hidden > 0 && {t('produced.more', { count: String(hidden) })}} +
+ ) +} diff --git a/packages/client/ui-conversation/src/client/chat/chat-flow.ts b/packages/client/ui-conversation/src/client/chat/chat-flow.ts index 4958894154..83ba5c463c 100644 --- a/packages/client/ui-conversation/src/client/chat/chat-flow.ts +++ b/packages/client/ui-conversation/src/client/chat/chat-flow.ts @@ -47,6 +47,43 @@ export function assistantActionsSeqs(nodes: readonly ConversationNode[]): Readon return new Set(lastByTurn.values()) } +/** + * Files each turn produced, keyed by the assistant seq that closes it — the + * same anchor {@link assistantActionsSeqs} elects, so the row lands under the + * message that reports the work rather than after some mid-turn narration. + * + * The source is the mutation tools' own follow-along `locations`, not the + * closing prose: a produced file must be listed whether or not the model + * remembered to name it. Reads contribute nothing (looking at a file does not + * produce it) and a failed mutation contributes nothing (there is no file to + * open). Paths keep first-seen order and appear once, so a file written and + * then edited in the same turn is one entry. + * @param nodes - snapshot nodes (surface order). + * @returns Per-closing-seq produced paths; a turn that produced none is absent. + */ +export function turnDeliverables(nodes: readonly ConversationNode[]): ReadonlyMap { + const closing = assistantActionsSeqs(nodes) + const byClosingSeq = new Map() + let pending: string[] = [] + const seen = new Set() + for (const node of nodes) { + if (node.kind === 'tool-result') { + if (node.isError || node.callView?.card !== 'diff') continue + for (const location of node.callView.locations ?? []) { + if (seen.has(location.path)) continue + seen.add(location.path) + pending.push(location.path) + } + continue + } + if (node.kind !== 'assistant' || !closing.has(node.seq)) continue + if (pending.length > 0) byClosingSeq.set(node.seq, pending) + pending = [] + seen.clear() + } + return byClosingSeq +} + /** * Group finalized nodes into the step-summary flow. * @param nodes - snapshot nodes in human-transcript and durable-notice order. diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 1bda57660d..78114fa2a8 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -53,6 +53,9 @@ export const zh = { 'message.unknownSurface': '未知 surface 事件:{type}', 'message.unknownBlock': '未知内容块', 'message.stopped': '已停止', + 'produced.label': '产物', + 'produced.more': '还有 {count} 个', + 'produced.open': '打开 {name}', 'message.branch': '在新对话中分支', 'message.retry.active': '正在重试模型请求', 'message.retry.cancelled': '模型请求重试已取消', @@ -152,6 +155,9 @@ export const en = { 'message.unknownSurface': 'Unknown surface event: {type}', 'message.unknownBlock': 'Unknown content block', 'message.stopped': 'Stopped', + 'produced.label': 'Produced', + 'produced.more': '{count} more', + 'produced.open': 'Open {name}', 'message.branch': 'Branch into a new conversation', 'message.retry.active': 'Retrying model request', 'message.retry.cancelled': 'Model request retry cancelled', diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 110ab6a991..6f855231ac 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -20,7 +20,7 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts import { createChatStore } from '../src/client/stores.ts' import { ChatView } from '../src/client/chat/ChatView.tsx' import { zh } from '../src/client/locales.ts' -import { assistantActionsSeqs, deriveChatFlow, flowKeys } from '../src/client/chat/chat-flow.ts' +import { assistantActionsSeqs, deriveChatFlow, flowKeys, turnDeliverables } from '../src/client/chat/chat-flow.ts' afterEach(cleanup) // Keyless create() persists under the bare declared key; clear between cases @@ -211,6 +211,61 @@ describe('chat-flow derivation', () => { ]) expect([...seqs].sort((a, b) => a - b)).toEqual([5, 7]) }) + + it('turnDeliverables attributes each turn’s written files to the assistant that closes it', () => { + const wrote = (seq: number, callId: string, ...paths: string[]): ToolResultNode => ({ + ...toolResult(seq, callId, 'write'), + callView: { + card: 'diff', title: `Write ${paths[0] ?? ''}`, + diffs: paths.map(path => ({ path, oldText: null, newText: 'x' })), + locations: paths.map(path => ({ path })), + }, + }) + const produced = turnDeliverables([ + user(1, 'build it'), + assistant(2, 'writing', 1), + wrote(3, 'a', 'out/index.html'), + // Same file touched twice in one turn is one deliverable, in first-seen order. + wrote(4, 'b', 'out/app.css', 'out/index.html'), + // A read is not a deliverable; a failed write has no file to open. + { ...toolResult(5, 'c', 'read'), callView: { card: 'generic', title: 'Read x', locations: [{ path: 'x.ts' }] } }, + { ...wrote(6, 'd', 'out/broken.html'), isError: true }, + assistant(7, 'done', 1), + user(8, 'again'), + assistant(9, 'second turn', 2), + ]) + expect(produced.get(7)).toEqual(['out/index.html', 'out/app.css']) + // A turn that produced nothing is absent, not an empty row. + expect(produced.has(9)).toBe(false) + // Nothing at all written: no entries. + expect(turnDeliverables([user(1, 'hi'), assistant(2, 'hello', 1)]).size).toBe(0) + }) + + it('renders the produced files under the closing message and opens one on click', () => { + const wrote = (seq: number, callId: string, ...paths: string[]): ToolResultNode => ({ + ...toolResult(seq, callId, 'write'), + callView: { + card: 'diff', title: 'Write', + diffs: paths.map(path => ({ path, oldText: null, newText: 'x' })), + locations: paths.map(path => ({ path })), + }, + }) + // Seven files: six chips plus an explicit remainder — the row bounds what + // it shows and says so rather than dropping the rest silently. + const paths = ['deep/a.html', 'b.css', 'c.ts', 'd.ts', 'e.ts', 'f.ts', 'g.ts'] + const h = makeHarness({ + nodes: [user(1, 'build it'), wrote(2, 'w', ...paths), assistant(3, 'done', 1)], + }) + const view = render() + expect(view.getByText('产物')).toBeTruthy() + // Chips carry the basename; the full path stays reachable as the title. + const chip = view.getByRole('button', { name: '打开 deep/a.html' }) + expect(chip.textContent).toBe('a.html') + expect(view.queryByRole('button', { name: '打开 g.ts' })).toBeNull() + expect(view.getByText('还有 1 个')).toBeTruthy() + fireEvent.click(chip) + expect(h.openFile).toHaveBeenCalledWith('deep/a.html') + }) }) describe('ChatView', () => { From f5d53f04b7f02f7ad69c0dd135b61ae4f16a7330 Mon Sep 17 00:00:00 2001 From: ZiyaZhang Date: Fri, 31 Jul 2026 23:20:36 -0700 Subject: [PATCH 006/104] cleanup(web): stop sandboxing served workspace documents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A preview lost localStorage and cookies under CSP sandbox — measurably, the reported artifact throws SecurityError on load and its theme toggle goes dead. The capability the sandbox denied is one the file's author, an agent already holding this user's shell, never needed the browser for, so the header sat behind a trust boundary it had already crossed. Isolating a preview becomes a real question when workspace content stops being the viewer's own; the answer then is a separate origin, not a header. --- ...6-07-31-web-workspace-file-links.i18n.yaml | 4 ++-- .../2026-07-31-web-workspace-file-links.md | 6 ++--- .../2026-07-31-web-workspace-file-links.zh.md | 6 ++--- packages/client/connection/README.i18n.yaml | 4 ++-- packages/client/connection/README.md | 2 +- packages/client/connection/README.zh.md | 2 +- .../client/connection/src/workspace-files.ts | 22 +++++++------------ .../connection/tests/workspace-files.spec.ts | 13 +++++------ 8 files changed, 26 insertions(+), 33 deletions(-) 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 2055af6cea..78f99d03c9 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: b7fd5ca240db3ca885e89f4cf6dcc135e7c88de8 -2026-07-31-web-workspace-file-links.zh.md: 74949afe0260d2d9018691740573ff24a1bce820 +2026-07-31-web-workspace-file-links.md: 3cd7a075f091a50a980cde14fdfcd15e810ee1f4 +2026-07-31-web-workspace-file-links.zh.md: 5702730938a76042879989ea961fdbe251830839 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 b7fd5ca240..3cd7a075f0 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 @@ -20,7 +20,7 @@ The parts were nearly all present, pointed at the wrong target. `ToolRow` alread **The URL shape lives in `dsh-host-apiproxy/api`, with the other browser-importable contract surfaces.** Both ends must agree on one encoding, but a client bundle may not value-import another plugin's package: the purity gate in `packages/client/tsdown.client.ts` allows only platform modules and the `INLINE_SAFE` wire layers, of which apiproxy is one. Putting `api/files.ts` there is what lets the browser half build a URL and the serving half parse it from a single source, and it needed no new package edge — both sides already depend on apiproxy. -**Model-authored documents are served into an opaque origin.** `.html`/`.htm`/`.xhtml`/`.svg` carry `Content-Security-Policy: sandbox allow-scripts allow-popups allow-modals allow-forms`. Serving generated markup same-origin with `/api` would put `/api/events.mux` — a readable `GET` stream — one `window.open` away from a page the model wrote. The sandbox costs the preview its `localStorage`, cookies, and same-origin `fetch`; `host.openPath` stays as the full-capability way to open the same file on the Host machine, so the trade is resolved by keeping both affordances rather than by weakening either. +**A served document carries no isolation header.** The first cut sandboxed script-capable documents, reasoning that `/api/events.mux` is a readable same-origin `GET` stream one `window.open` away from a page the model wrote. Measuring it settled the question the other way: under `CSP: sandbox` the report's own artifact throws `SecurityError` on `localStorage` and its theme toggle goes dead, and the capability the sandbox denies is one the page's author — an agent already holding this user's shell — never needed the browser for. A sandbox there sits behind a trust boundary it has already crossed. The rejected middle option (`connect-src 'none'` plus a `Sec-Fetch-Dest: document` refusal on the two SSE `GET`s) restored the preview but was the only option that had to edit the RPC gateway, and its fence goes quiet over plain-HTTP LAN, where `Sec-Fetch-*` is not sent. Isolating a preview becomes a real question when workspace content stops being the viewer's own; the answer then is a separate origin, not a header. **The client decides by derivation, not by probing.** `IWorkspaces.fileUrl(sessionId, cwd, path)` expresses a tool-reported path as segments below the session cwd and returns the origin-relative URL, or `undefined` when the path leaves the workspace. `undefined` is exactly the signal to fall back to `openPath`, so a file outside the workspace behaves as it did before and no capability negotiation is needed. @@ -30,9 +30,9 @@ The parts were nearly all present, pointed at the wrong target. `ToolRow` alread - **A dedicated `dsh-client-workspace-files` package** — the honest seam shape if file serving were an independent capability. It is not: it needs the same fence and the same `trustedHosts` value as `/api`, and splitting would have duplicated both against the repository's own "don't split preemptively" rule. - **Keeping the URL-shape module in `client-connection` and importing it from the runtime** — the first cut, and the build refused it: a cross-plugin value import into a client bundle either inlines a duplicate runtime instance or names a specifier the frozen module table cannot answer. The gate is the reason the shared module sits in the wire layer rather than in the package that happens to own the route. - **`/f/`, so `openPath` could stay the single call site** — drops the sessionId from the URL, but then the served authority becomes the union of every workspace the host knows. The tight authority costs exactly one call-site edit, because `openFile` already has both the sessionId and the cwd in scope. -- **`connect-src 'none'` instead of `sandbox`, to keep `localStorage` working** — blocks `fetch`/`EventSource` but not `window.open('/api/events.mux')`, which is readable same-origin. The two GET SSE endpoints are what make the sandbox necessary rather than optional. +- **`connect-src 'none'` plus a navigation fence, to keep `localStorage` working under isolation** — measurably viable (Chrome sends `Sec-Fetch-Dest: document` for `window.open` and `empty` for `EventSource`, loopback included), and rejected anyway: it was the only option adding a rule to the RPC gateway, and the header it depends on is absent over plain-HTTP LAN. More mechanism than the threat it removes. - **Linkifying paths in the assistant's closing message** — the shape a user asks for ("put the link at the end"), but it makes rendering depend on the model spelling a path recognizably. The tool calls already carry `locations` as structured fact; consuming that is the reliable source and is left as the follow-up this route unblocks. ## Consequences -Every existing file affordance changed target at once: write, edit, read, and the generic single-file card all reach `openFile`, so one call-site edit made produced files openable in the browser, LAN clients included. Three tests asserting the old `openPath` destination were rewritten to the new one; the outside-workspace fallback keeps the old assertion. The route is covered against a real HTTP server and a real temporary workspace, because confinement, content typing, and the sandbox header are wire facts, and the assembled web lane (`apps/web/tests/workspace-file-open.e2e.ts`, keyless over a cold-seeded session) proves the product path: clicking a read row's path opens `/f//a.txt` in a second tab serving that workspace file, while a traversal spelling answers 404. `localStorage` is unavailable inside a preview, which is visible on generated pages that persist a theme toggle — the Host opener remains for those. Still deferred: the end-of-turn deliverable row derived from `locations`, and any linkification inside assistant Markdown. +Every existing file affordance changed target at once: write, edit, read, and the generic single-file card all reach `openFile`, so one call-site edit made produced files openable in the browser, LAN clients included. Three tests asserting the old `openPath` destination were rewritten to the new one; the outside-workspace fallback keeps the old assertion. The route is covered against a real HTTP server and a real temporary workspace, because confinement, content typing, and the sandbox header are wire facts, and the assembled web lane (`apps/web/tests/workspace-file-open.e2e.ts`, keyless over a cold-seeded session) proves the product path: clicking a read row's path opens `/f//a.txt` in a second tab serving that workspace file, while a traversal spelling answers 404. A preview keeps its own capabilities, so a generated page that persists a theme in `localStorage` works as its author intended. Still deferred: the end-of-turn deliverable row derived from `locations`, and any linkification inside assistant Markdown. 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 74949afe02..5702730938 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 @@ -20,7 +20,7 @@ Status: implemented **URL 形状落在 `dsh-host-apiproxy/api`,与其余浏览器可导入的契约面同处一地。** 两端必须就同一套编码达成一致,但客户端 bundle 不允许值导入另一个插件的包:`packages/client/tsdown.client.ts` 里的纯度 gate 只放行平台模块与 `INLINE_SAFE` 协议层,而 apiproxy 正是其中之一。把 `api/files.ts` 放在那里,才使构造 URL 的浏览器半侧与解析它的服务半侧共用单一来源,而且没有新增任何包依赖边——两侧本来就依赖 apiproxy。 -**模型撰写的文档被送进不透明源。** `.html`/`.htm`/`.xhtml`/`.svg` 会带上 `Content-Security-Policy: sandbox allow-scripts allow-popups allow-modals allow-forms`。若把生成的标记与 `/api` 同源提供,`/api/events.mux`——一条可读的 `GET` 流——离模型写的页面就只有一次 `window.open` 之遥。sandbox 让预览失去 `localStorage`、cookie 与同源 `fetch`;`host.openPath` 作为在 Host 机器上以完整能力打开同一文件的方式保留下来,因此这个取舍是靠同时保留两个交互解决的,而不是靠削弱其中之一。 +**所服务的文档不带任何隔离头。** 最初的做法是给能执行脚本的文档加 sandbox,理由是 `/api/events.mux` 是一条同源可读的 `GET` 流,离模型写的页面只有一次 `window.open` 之遥。实测把这个问题判向了另一边:在 `CSP: sandbox` 之下,报告中那份产物自己就会在 `localStorage` 上抛 `SecurityError`,主题切换当场变死;而 sandbox 所拒绝的那项能力,对这个页面的作者——一个已经握着本用户 shell 的 agent——而言从来就不需要经由浏览器取得。那道 sandbox 立在一条它早已越过的信任边界之后。被否掉的折中方案(`connect-src 'none'` 加上对两个 SSE `GET` 拒绝 `Sec-Fetch-Dest: document`)确实能救回预览,但它是唯一必须去改 RPC 网关的方案,而它依赖的那个头在明文 HTTP 的 LAN 上根本不发送。当工作区内容不再属于观看者本人时,隔离预览才成为一个真问题;那时的答案是一个独立的源,而不是一个头。 **客户端靠推导决定,而不是靠探测。** `IWorkspaces.fileUrl(sessionId, cwd, path)` 把工具报告的路径表达为 session cwd 之下的段落并返回相对于源的 URL,路径离开工作区时返回 `undefined`。`undefined` 恰好就是回退到 `openPath` 的信号,因此工作区外的文件行为与以往一致,也不需要任何能力协商。 @@ -30,9 +30,9 @@ Status: implemented - **单开一个 `dsh-client-workspace-files` 包**——如果文件服务是一项独立能力,这才是诚实的 seam 形状。它不是:它需要与 `/api` 相同的 fence 和相同的 `trustedHosts` 值,拆分会把两者都复制一份,违背仓库自己的“不要预先拆分”。 - **把 URL 形状模块留在 `client-connection` 里、由 runtime 去导入**——最初就是这么写的,构建直接拒绝:向客户端 bundle 做跨插件值导入,要么内联出一份重复的运行时实例,要么落到冻结模块表答不出的说明符上。这道 gate 正是共享模块落在协议层、而非落在恰好持有该路由的那个包里的原因。 - **`/f/<绝对路径>`,好让 `openPath` 保持为唯一调用点**——这会把 sessionId 从 URL 里去掉,但所服务的权限边界随之变成 host 已知的全部工作区之并集。紧的权限边界只花掉一处调用点的改动,因为 `openFile` 本来就同时持有 sessionId 与 cwd。 -- **用 `connect-src 'none'` 代替 `sandbox`,以保住 `localStorage`**——它挡得住 `fetch`/`EventSource`,挡不住 `window.open('/api/events.mux')`,而后者是同源可读的。正是那两个 GET SSE 端点让 sandbox 成为必需而非可选。 +- **用 `connect-src 'none'` 加一道导航栅栏,在保持隔离的同时保住 `localStorage`**——经实测确实可行(Chrome 对 `window.open` 发 `Sec-Fetch-Dest: document`、对 `EventSource` 发 `empty`,回环也在内),但仍被否:它是唯一要往 RPC 网关里加规则的方案,而它依赖的那个头在明文 HTTP 的 LAN 上并不发送。机制的分量超过了它移除的威胁。 - **把路径在助手的收尾消息里链接化**——这是用户开口要的形状(“在结尾附上链接”),但它让渲染取决于模型是否把路径拼写得可识别。工具调用已经把 `locations` 作为结构化事实携带;消费它才是可靠来源,作为这条路由解锁的后续留下。 ## 影响 -现有的每一处文件交互都同时换了目标:write、edit、read 与通用单文件卡片都汇到 `openFile`,因此一处调用点的改动就让产出的文件在浏览器里可打开,LAN 客户端也在内。三个断言旧 `openPath` 去向的测试被改写为新的去向;工作区外的回退保留了旧断言。这条路由对着真实 HTTP 服务器与真实临时工作区做覆盖,因为收敛、内容定型与 sandbox 头都是协议事实;而组装后的 web 通道(`apps/web/tests/workspace-file-open.e2e.ts`,在冷播种会话上无密钥运行)证明了产品路径:点击读取行的路径会在第二个标签页打开 `/f//a.txt` 并提供那个工作区文件,而穿越写法应答 404。预览中无法使用 `localStorage`,这在会持久化主题切换的生成页面上是看得见的——那些场景仍有 Host 打开器。仍然暂缓:由 `locations` 推导的回合末交付物行,以及助手 Markdown 内部的任何链接化。 +现有的每一处文件交互都同时换了目标:write、edit、read 与通用单文件卡片都汇到 `openFile`,因此一处调用点的改动就让产出的文件在浏览器里可打开,LAN 客户端也在内。三个断言旧 `openPath` 去向的测试被改写为新的去向;工作区外的回退保留了旧断言。这条路由对着真实 HTTP 服务器与真实临时工作区做覆盖,因为收敛、内容定型与 sandbox 头都是协议事实;而组装后的 web 通道(`apps/web/tests/workspace-file-open.e2e.ts`,在冷播种会话上无密钥运行)证明了产品路径:点击读取行的路径会在第二个标签页打开 `/f//a.txt` 并提供那个工作区文件,而穿越写法应答 404。预览保有自身的能力,因此把主题持久化到 `localStorage` 的生成页面,按其作者的意图正常工作。仍然暂缓:由 `locations` 推导的回合末交付物行,以及助手 Markdown 内部的任何链接化。 diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 101d8fd61b..452ffe81c8 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: 9a08cb4de5531b044bd411ea08595c22f88e5f8a -README.zh.md: cfc427945f42b61288f57f5ca1db9af74dbcfb31 +README.md: ebc2dea2787268e1686eac24565c2433cb5f4b66 +README.zh.md: f5653e9e0e0aed3cbf7a9e6124668942f4dde44a diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 9a08cb4de5..ebc2dea278 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -12,7 +12,7 @@ The node half guards every request under `/api` before bridging (`src/api-reques The node half also serves one file at a time out of a Session's workspace under `/f//`, so a produced deliverable is reachable from the page that reported it — an `http` page cannot follow a `file://` link, and a browser that is not on the Host machine has no such path anyway. The segments ride the URL rather than a query parameter so a served document's relative references resolve to its siblings. The request names a Session and the gateway names that Session's directory (`ApiProxy.workspaceRootOf`, which answers from a live agent's header or the persistence store and never resumes an agent to serve a file); this package reads the authority rather than the core services, because holding their host-side Context declarations would merge them over the browser runtime's own. The URL shape itself lives with the other browser-importable contract surfaces, in [`@deepseek-ai/dsh-host-apiproxy/api`](../../host/apiproxy/README.md), so the browser half that builds a URL and this half that parses one share a single encoding decision. Both the cwd and the resolved target go through `realpath` before comparison, so a symlink inside the workspace pointing out of it is refused by its target rather than its name; traversal spellings are refused earlier still, at parse time, before any filesystem call. Reads stream (no request buffers a file), answer `GET`/`HEAD` only, and carry `nosniff` with `no-store`. Extensions outside the served content-type table are typed `text/plain` rather than offered as a download, because a workspace read is a request to see a file. -Documents that can execute script — `.html`, `.htm`, `.xhtml`, `.svg` — additionally carry `Content-Security-Policy: sandbox allow-scripts allow-popups allow-modals allow-forms`. Model-authored markup is served from the same origin as `/api`, where `/api/events.mux` is a readable `GET` stream, so an opaque origin is what keeps a generated page from reading the session event stream one `window.open` away. The cost is borne by the preview: `localStorage`, cookies, and same-origin `fetch` are unavailable inside it, and `host.openPath` remains the full-capability way to open the same file on the Host machine. The same trust fence gates this prefix, so a `trustedHosts` deployment serves workspace files exactly where it serves ordinary reads. +A served document carries no isolation header and is same-origin with `/api`. That is a decision, not an omission: the only author of these files is the agent already holding this user's shell and filesystem, so a `Content-Security-Policy: sandbox` would sit behind a trust boundary it has already crossed while costing every preview its `localStorage` and cookies — a generated page that remembers a theme breaks under it. A deployment that serves `dsh web` beyond loopback should treat workspace content as trusted, which is already true of everything else its agent does. Isolating a preview becomes a real question when workspace content stops being the viewer's own; the answer then is a separate origin, not a header. The same trust fence gates this prefix, so a `trustedHosts` deployment serves workspace files exactly where it serves ordinary reads. ## Keyless fixture diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index cfc427945f..f5653e9e0e 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -12,7 +12,7 @@ node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust node 半侧还会在 `/f//` 下逐个提供某个 Session 工作区里的文件,让产出的交付物能从报告它的那个页面直接抵达——`http` 页面无法跟随 `file://` 链接,而不在 Host 机器上的浏览器本来也没有那条路径。段落走 URL 而非查询参数,是为了让所服务文档的相对引用能解析到它的同级文件。请求指名一个 Session,由网关指名该 Session 的目录(`ApiProxy.workspaceRootOf`,它从活跃 agent 的 header 或持久化存储作答,绝不会为了提供一个文件而恢复 agent);本包读取这个权威来源而不去够核心服务,因为持有它们的 host 侧 Context 声明会把它们盖到浏览器运行时自己的声明之上。URL 形状本身与其余浏览器可导入的契约面放在一起,位于 [`@deepseek-ai/dsh-host-apiproxy/api`](../../host/apiproxy/README.md),因此构造 URL 的浏览器半侧与解析 URL 的这一半共享同一个编码决定。cwd 与解析出的目标在比较前都要过 `realpath`,因此工作区内指向工作区外的符号链接会因其目标而被拒绝,而不是因其名字;穿越写法拒得更早,在解析期、任何文件系统调用之前。读取是流式的(没有请求会把文件缓冲起来),只应答 `GET`/`HEAD`,并带上 `nosniff` 与 `no-store`。所服务的内容类型表之外的扩展名一律按 `text/plain` 定型而非作为下载给出,因为工作区读取本就是一个“让我看看这个文件”的请求。 -能执行脚本的文档——`.html`、`.htm`、`.xhtml`、`.svg`——还会额外带上 `Content-Security-Policy: sandbox allow-scripts allow-popups allow-modals allow-forms`。模型撰写的标记与 `/api` 同源提供,而 `/api/events.mux` 是一条可读的 `GET` 流,因此正是不透明源阻止了一个生成页面通过一次 `window.open` 读走会话事件流。代价由预览承担:其中无法使用 `localStorage`、cookie 与同源 `fetch`,而 `host.openPath` 仍是在 Host 机器上以完整能力打开同一文件的方式。这条前缀由同一道信任 fence 把守,因此配置了 `trustedHosts` 的部署提供工作区文件的范围,与它提供普通读取的范围完全一致。 +所服务的文档不带任何隔离头,与 `/api` 同源。这是一个决定,不是遗漏:这些文件的唯一作者,正是那个已经握着本用户 shell 与文件系统的 agent,因此 `Content-Security-Policy: sandbox` 只会立在一条它早已越过的信任边界之后,代价却是每个预览都失去 `localStorage` 与 cookie——一个会记住主题的生成页面在它之下就是坏的。把 `dsh web` 服务到回环之外的部署,应当把工作区内容按可信处理,而这一点对其 agent 所做的其他一切本来就已成立。当工作区内容不再属于观看者本人时,隔离预览才成为一个真问题;那时的答案是一个独立的源,而不是一个头。这条前缀由同一道信任 fence 把守,因此配置了 `trustedHosts` 的部署提供工作区文件的范围,与它提供普通读取的范围完全一致。 ## 无密钥 fixture diff --git a/packages/client/connection/src/workspace-files.ts b/packages/client/connection/src/workspace-files.ts index 9ad1b830a3..354b7b2225 100644 --- a/packages/client/connection/src/workspace-files.ts +++ b/packages/client/connection/src/workspace-files.ts @@ -9,6 +9,14 @@ * names its cwd, and nothing outside that realpath is ever served. The caller * owns the browser-trust fence ([api-request-trust](./api-request-trust.ts)) — * this module is reached only by requests that already passed it. + * + * A served document is same-origin with `/api`, and deliberately carries no + * isolation header. The only author of these files is the agent already + * holding this user's shell and filesystem, so a browser sandbox would not + * move the trust boundary — it would sit behind one already crossed, at the + * cost of `localStorage` and cookies in every preview. Isolating a preview + * becomes a real question when workspace content stops being the viewer's own; + * the answer then is a separate origin, not a header. */ import { createReadStream } from 'node:fs' @@ -51,19 +59,6 @@ const MIME: Record = { const DEFAULT_MIME = 'text/plain; charset=utf-8' -/** Extensions whose top-level navigation can execute script, and so need the sandbox. */ -const SCRIPTABLE = new Set(['.html', '.htm', '.xhtml', '.svg']) - -/** - * Model-authored documents run in an opaque origin. Without it a generated page - * is same-origin with the RPC gateway, where `/api/events.mux` is a readable - * GET stream — one `window.open` away from every session's events. The cost is - * that `localStorage`, cookies, and same-origin `fetch` are unavailable inside - * a preview; the native-open path (`host.openPath`) remains the full-capability - * way to view a file. - */ -const SANDBOX_CSP = 'sandbox allow-scripts allow-popups allow-modals allow-forms' - /** How the route learns which directory a session may serve from. */ export interface WorkspaceFileDeps { /** @@ -151,7 +146,6 @@ export async function handleWorkspaceFile( // Workspace files change under the agent's hands; a cached preview would // show the previous turn's output after the next edit. 'cache-control': 'no-store', - ...SCRIPTABLE.has(ext) ? { 'content-security-policy': SANDBOX_CSP } : {}, }) if (req.method === 'HEAD') { res.end() diff --git a/packages/client/connection/tests/workspace-files.spec.ts b/packages/client/connection/tests/workspace-files.spec.ts index fb4288a8b2..eb36fdd6ea 100644 --- a/packages/client/connection/tests/workspace-files.spec.ts +++ b/packages/client/connection/tests/workspace-files.spec.ts @@ -58,26 +58,25 @@ function get(path: string, init?: RequestInit): Promise { } describe('workspace file reads', () => { - it('serves a produced document with the sandbox that keeps it off this origin', async () => { + it('serves a produced document with its own capabilities intact', async () => { const response = await get(`${FILES_PATH}/${SESSION}/index.html`) expect(response.status).toBe(200) expect(await response.text()).toBe('

产物

') expect(response.headers.get('content-type')).toBe('text/html; charset=utf-8') - // The whole reason a model-authored page may be served from the RPC - // origin: an opaque origin cannot read /api/events.mux. - expect(response.headers.get('content-security-policy')).toContain('sandbox') + // No isolation header: a preview keeps localStorage and cookies, because + // the file's author already holds this user's shell (see the module doc). + expect(response.headers.get('content-security-policy')).toBeNull() expect(response.headers.get('x-content-type-options')).toBe('nosniff') expect(response.headers.get('cache-control')).toBe('no-store') expect(response.headers.get('content-disposition')).toBe('inline') }) - it('sandboxes SVG too, and leaves non-scriptable types alone', async () => { + it('types SVG as a standalone document rather than sniffable bytes', async () => { const svg = await get(`${FILES_PATH}/${SESSION}/chart.svg`) expect(svg.headers.get('content-type')).toBe('image/svg+xml') - expect(svg.headers.get('content-security-policy')).toContain('sandbox') + expect(svg.headers.get('x-content-type-options')).toBe('nosniff') const text = await get(`${FILES_PATH}/${SESSION}/notes.txt`) expect(text.headers.get('content-type')).toBe('text/plain; charset=utf-8') - expect(text.headers.get('content-security-policy')).toBeNull() }) it('shows an unknown extension as text rather than downloading it', async () => { From dcf485ac5c2ee7373b0de683871cc63eb4a83152 Mon Sep 17 00:00:00 2001 From: ZiyaZhang Date: Sat, 1 Aug 2026 01:08:16 -0700 Subject: [PATCH 007/104] fix(web): address the review of the workspace-file route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Isolation is restored on the premise the review corrected: a workspace file need not be agent-authored — a read row makes every file in a cloned repository openable — and a same-origin active document was measured driving /api/settings.describe to a 200 with full data. Script-capable documents go back into an opaque origin; the preview's lost localStorage is the known cost, and a separate serving origin is the way to retire it. - confine(): a workspace rooted at a filesystem root has a realpath already ending in the separator, and the doubled prefix 403'd every child. - turnDeliverables(): reset on the turn boundary, not only at a closing assistant, so an interrupted turn cannot spill into the next turn's row; and recognize a mutation by render intent (diff card, or generic with kind 'edit') so str_replace_editor's insert counts. - 405 answers name the methods it allows. - The e2e now cold-seeds a recorded WRITE turn, so the assembled application covers the Produced row, its chip's served URL, and the isolation header. - Agent Note matched to what shipped (the row is in this PR, not deferred); ui-conversation README documents the new destination and the row; the fixture lane's dead-tab quirk and the cold-path listing cost are recorded. --- ...6-07-31-web-workspace-file-links.i18n.yaml | 4 +- .../2026-07-31-web-workspace-file-links.md | 11 ++-- .../2026-07-31-web-workspace-file-links.zh.md | 11 ++-- apps/web/tests/workspace-file-open.e2e.ts | 66 ++++++++++--------- packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 4 +- packages/client/connection/README.zh.md | 4 +- packages/client/connection/src/index.ts | 3 +- .../client/connection/src/workspace-files.ts | 31 ++++++--- .../client/connection/tests/node-half.spec.ts | 11 +++- .../connection/tests/workspace-files.spec.ts | 27 +++++--- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 4 +- packages/client/ui-conversation/README.zh.md | 4 +- .../src/client/chat/chat-flow.ts | 62 +++++++++++++---- .../ui-conversation/tests/chat-view.spec.tsx | 32 +++++++++ packages/host/apiproxy/src/api-proxy.ts | 4 ++ 17 files changed, 205 insertions(+), 81 deletions(-) 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 78f99d03c9..584f3c899d 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: 3cd7a075f091a50a980cde14fdfcd15e810ee1f4 -2026-07-31-web-workspace-file-links.zh.md: 5702730938a76042879989ea961fdbe251830839 +2026-07-31-web-workspace-file-links.md: a4bd8a2fecc2f1cb29d61a575b1ae0d31e5ebdd3 +2026-07-31-web-workspace-file-links.zh.md: 1d001710b2548343cb811fd6cca282cf956e95d9 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 3cd7a075f0..a4bd8a2fec 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 @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-07-31-web-workspace-file-links.zh.md) -> Scope: the `/f` workspace-file route on the web transport, the `IWorkspaces.fileUrl` derivation behind it, and the conversation's file-open affordance switching to it. Not in scope: an artifact registry, versioning, live reload, or any model-facing declaration. +> Scope: the `/f` workspace-file route on the web transport, the `IWorkspaces.fileUrl` derivation behind it, the conversation's file-open affordance switching to it, and the produced-files row a finished turn ends with. Not in scope: an artifact registry, versioning, live reload, or any model-facing declaration. ## Problem @@ -20,7 +20,7 @@ The parts were nearly all present, pointed at the wrong target. `ToolRow` alread **The URL shape lives in `dsh-host-apiproxy/api`, with the other browser-importable contract surfaces.** Both ends must agree on one encoding, but a client bundle may not value-import another plugin's package: the purity gate in `packages/client/tsdown.client.ts` allows only platform modules and the `INLINE_SAFE` wire layers, of which apiproxy is one. Putting `api/files.ts` there is what lets the browser half build a URL and the serving half parse it from a single source, and it needed no new package edge — both sides already depend on apiproxy. -**A served document carries no isolation header.** The first cut sandboxed script-capable documents, reasoning that `/api/events.mux` is a readable same-origin `GET` stream one `window.open` away from a page the model wrote. Measuring it settled the question the other way: under `CSP: sandbox` the report's own artifact throws `SecurityError` on `localStorage` and its theme toggle goes dead, and the capability the sandbox denies is one the page's author — an agent already holding this user's shell — never needed the browser for. A sandbox there sits behind a trust boundary it has already crossed. The rejected middle option (`connect-src 'none'` plus a `Sec-Fetch-Dest: document` refusal on the two SSE `GET`s) restored the preview but was the only option that had to edit the RPC gateway, and its fence goes quiet over plain-HTTP LAN, where `Sec-Fetch-*` is not sent. Isolating a preview becomes a real question when workspace content stops being the viewer's own; the answer then is a separate origin, not a header. +**Script-capable documents are served into an opaque origin.** `.html`/`.htm`/`.xhtml`/`.svg` carry `Content-Security-Policy: sandbox allow-scripts allow-popups allow-modals allow-forms`. The decision was briefly taken the other way, on the premise that these files are agent-authored and their author already holds this user's shell, so a browser sandbox would sit behind a trust boundary already crossed. Review falsified the premise: a workspace file need not be agent-authored at all — a read row makes every file in a cloned repository openable — and a same-origin active document was measured driving `/api/settings.describe` to a `200` with full data, so the loopback-pinned settings and credential plane was reachable from a page nobody in this session wrote. The cost is real and stays: a preview has no `localStorage` or cookies, so a generated page that remembers a theme will not. Restoring that without reopening the hole needs a separate origin, which is a different change; `host.openPath` remains the full-capability way to open the same file. **The client decides by derivation, not by probing.** `IWorkspaces.fileUrl(sessionId, cwd, path)` expresses a tool-reported path as segments below the session cwd and returns the origin-relative URL, or `undefined` when the path leaves the workspace. `undefined` is exactly the signal to fall back to `openPath`, so a file outside the workspace behaves as it did before and no capability negotiation is needed. @@ -30,9 +30,10 @@ The parts were nearly all present, pointed at the wrong target. `ToolRow` alread - **A dedicated `dsh-client-workspace-files` package** — the honest seam shape if file serving were an independent capability. It is not: it needs the same fence and the same `trustedHosts` value as `/api`, and splitting would have duplicated both against the repository's own "don't split preemptively" rule. - **Keeping the URL-shape module in `client-connection` and importing it from the runtime** — the first cut, and the build refused it: a cross-plugin value import into a client bundle either inlines a duplicate runtime instance or names a specifier the frozen module table cannot answer. The gate is the reason the shared module sits in the wire layer rather than in the package that happens to own the route. - **`/f/`, so `openPath` could stay the single call site** — drops the sessionId from the URL, but then the served authority becomes the union of every workspace the host knows. The tight authority costs exactly one call-site edit, because `openFile` already has both the sessionId and the cwd in scope. -- **`connect-src 'none'` plus a navigation fence, to keep `localStorage` working under isolation** — measurably viable (Chrome sends `Sec-Fetch-Dest: document` for `window.open` and `empty` for `EventSource`, loopback included), and rejected anyway: it was the only option adding a rule to the RPC gateway, and the header it depends on is absent over plain-HTTP LAN. More mechanism than the threat it removes. -- **Linkifying paths in the assistant's closing message** — the shape a user asks for ("put the link at the end"), but it makes rendering depend on the model spelling a path recognizably. The tool calls already carry `locations` as structured fact; consuming that is the reliable source and is left as the follow-up this route unblocks. +- **`connect-src 'none'` plus a navigation fence, to keep `localStorage` working under isolation** — measurably viable against the SSE-read vector (Chrome sends `Sec-Fetch-Dest: document` for `window.open` and `empty` for `EventSource`, loopback included), but it never addressed the larger one: same-origin `fetch` to a POST method is what reaches the configuration plane, and blocking `connect-src` from the served document is exactly what a hostile document would not do to itself. Only an origin boundary contains it. +- **Serving `/f` from its own loopback port** — the one option that keeps both isolation and preview capabilities, and the shape a separate-origin answer would take. Deferred, not rejected: it needs a second listener with its own lifecycle plus the port plumbed to the client, which is a change of a different size than this one. +- **Linkifying paths in the assistant's closing message** — the shape a user asks for ("put the link at the end"), but it makes rendering depend on the model spelling a path recognizably. The tool calls already carry `locations` as structured fact, so the produced-files row consumes that instead; linkifying the prose stays unnecessary rather than merely deferred. ## Consequences -Every existing file affordance changed target at once: write, edit, read, and the generic single-file card all reach `openFile`, so one call-site edit made produced files openable in the browser, LAN clients included. Three tests asserting the old `openPath` destination were rewritten to the new one; the outside-workspace fallback keeps the old assertion. The route is covered against a real HTTP server and a real temporary workspace, because confinement, content typing, and the sandbox header are wire facts, and the assembled web lane (`apps/web/tests/workspace-file-open.e2e.ts`, keyless over a cold-seeded session) proves the product path: clicking a read row's path opens `/f//a.txt` in a second tab serving that workspace file, while a traversal spelling answers 404. A preview keeps its own capabilities, so a generated page that persists a theme in `localStorage` works as its author intended. Still deferred: the end-of-turn deliverable row derived from `locations`, and any linkification inside assistant Markdown. +Every existing file affordance changed target at once: write, edit, read, and the generic single-file card all reach `openFile`, so one call-site edit made produced files openable in the browser, LAN clients included. Three tests asserting the old `openPath` destination were rewritten to the new one; the outside-workspace fallback keeps the old assertion. The route is covered against a real HTTP server and a real temporary workspace, because confinement, content typing, and the sandbox header are wire facts, and the assembled web lane (`apps/web/tests/workspace-file-open.e2e.ts`, keyless over a cold-seeded session) proves the product path: clicking a read row's path opens `/f//a.txt` in a second tab serving that workspace file, while a traversal spelling answers 404. A preview runs without `localStorage` or cookies, visible on generated pages that persist a theme — the Host opener remains for those, and a separate serving origin is the way to retire the limitation. The produced-files row ships here too: `turnDeliverables` reads a turn's output off the mutation tools' render intent (a diff card, or a generic card whose `kind` is `edit`), resets on the turn boundary so an interrupted turn cannot spill into the next, and renders under the closing assistant. Still deferred: linkification inside assistant Markdown, and any cross-session view of past deliverables. 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 5702730938..1d001710b2 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 @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-31-web-workspace-file-links.md) | 中文 -> 范围:web 传输层上的 `/f` 工作区文件路由、其背后的 `IWorkspaces.fileUrl` 推导,以及会话中打开文件的交互改指向它。不在范围内:产物注册表、版本、实时重载,或任何面向模型的声明。 +> 范围:web 传输层上的 `/f` 工作区文件路由、其背后的 `IWorkspaces.fileUrl` 推导、会话中打开文件的交互改指向它,以及完成的一轮以其产出文件收尾的那一行。不在范围内:产物注册表、版本、实时重载,或任何面向模型的声明。 ## 问题 @@ -20,7 +20,7 @@ Status: implemented **URL 形状落在 `dsh-host-apiproxy/api`,与其余浏览器可导入的契约面同处一地。** 两端必须就同一套编码达成一致,但客户端 bundle 不允许值导入另一个插件的包:`packages/client/tsdown.client.ts` 里的纯度 gate 只放行平台模块与 `INLINE_SAFE` 协议层,而 apiproxy 正是其中之一。把 `api/files.ts` 放在那里,才使构造 URL 的浏览器半侧与解析它的服务半侧共用单一来源,而且没有新增任何包依赖边——两侧本来就依赖 apiproxy。 -**所服务的文档不带任何隔离头。** 最初的做法是给能执行脚本的文档加 sandbox,理由是 `/api/events.mux` 是一条同源可读的 `GET` 流,离模型写的页面只有一次 `window.open` 之遥。实测把这个问题判向了另一边:在 `CSP: sandbox` 之下,报告中那份产物自己就会在 `localStorage` 上抛 `SecurityError`,主题切换当场变死;而 sandbox 所拒绝的那项能力,对这个页面的作者——一个已经握着本用户 shell 的 agent——而言从来就不需要经由浏览器取得。那道 sandbox 立在一条它早已越过的信任边界之后。被否掉的折中方案(`connect-src 'none'` 加上对两个 SSE `GET` 拒绝 `Sec-Fetch-Dest: document`)确实能救回预览,但它是唯一必须去改 RPC 网关的方案,而它依赖的那个头在明文 HTTP 的 LAN 上根本不发送。当工作区内容不再属于观看者本人时,隔离预览才成为一个真问题;那时的答案是一个独立的源,而不是一个头。 +**能执行脚本的文档被送进不透明源。** `.html`/`.htm`/`.xhtml`/`.svg` 带上 `Content-Security-Policy: sandbox allow-scripts allow-popups allow-modals allow-forms`。这个决定曾短暂地被判向另一边,前提是这些文件由 agent 撰写、而其作者已经握着本用户的 shell,因此浏览器 sandbox 只会立在一条早已越过的信任边界之后。评审推翻了这个前提:工作区文件根本不必由 agent 撰写——一条 read 行就让 clone 下来的仓库里任何文件变得可打开——而同源的活动文档经实测能把 `/api/settings.describe` 打到 `200` 并拿到完整数据,也就是说被钉在回环的设置与凭据面,可以被一个本次会话中无人撰写的页面触达。代价真实且保留:预览没有 `localStorage` 与 cookie,因此会记住主题的生成页面在其中记不住。要在不重新打开这个洞的前提下取回它,需要一个独立的源,那是另一个尺寸的改动;`host.openPath` 仍是以完整能力打开同一文件的方式。 **客户端靠推导决定,而不是靠探测。** `IWorkspaces.fileUrl(sessionId, cwd, path)` 把工具报告的路径表达为 session cwd 之下的段落并返回相对于源的 URL,路径离开工作区时返回 `undefined`。`undefined` 恰好就是回退到 `openPath` 的信号,因此工作区外的文件行为与以往一致,也不需要任何能力协商。 @@ -30,9 +30,10 @@ Status: implemented - **单开一个 `dsh-client-workspace-files` 包**——如果文件服务是一项独立能力,这才是诚实的 seam 形状。它不是:它需要与 `/api` 相同的 fence 和相同的 `trustedHosts` 值,拆分会把两者都复制一份,违背仓库自己的“不要预先拆分”。 - **把 URL 形状模块留在 `client-connection` 里、由 runtime 去导入**——最初就是这么写的,构建直接拒绝:向客户端 bundle 做跨插件值导入,要么内联出一份重复的运行时实例,要么落到冻结模块表答不出的说明符上。这道 gate 正是共享模块落在协议层、而非落在恰好持有该路由的那个包里的原因。 - **`/f/<绝对路径>`,好让 `openPath` 保持为唯一调用点**——这会把 sessionId 从 URL 里去掉,但所服务的权限边界随之变成 host 已知的全部工作区之并集。紧的权限边界只花掉一处调用点的改动,因为 `openFile` 本来就同时持有 sessionId 与 cwd。 -- **用 `connect-src 'none'` 加一道导航栅栏,在保持隔离的同时保住 `localStorage`**——经实测确实可行(Chrome 对 `window.open` 发 `Sec-Fetch-Dest: document`、对 `EventSource` 发 `empty`,回环也在内),但仍被否:它是唯一要往 RPC 网关里加规则的方案,而它依赖的那个头在明文 HTTP 的 LAN 上并不发送。机制的分量超过了它移除的威胁。 -- **把路径在助手的收尾消息里链接化**——这是用户开口要的形状(“在结尾附上链接”),但它让渲染取决于模型是否把路径拼写得可识别。工具调用已经把 `locations` 作为结构化事实携带;消费它才是可靠来源,作为这条路由解锁的后续留下。 +- **用 `connect-src 'none'` 加一道导航栅栏,在保持隔离的同时保住 `localStorage`**——针对“读走 SSE 流”这条向量经实测可行(Chrome 对 `window.open` 发 `Sec-Fetch-Dest: document`、对 `EventSource` 发 `empty`,回环也在内),但它从未触及更大的那条:真正够到配置面的是向 POST 方法发起的同源 `fetch`,而“从所服务文档一侧封住 `connect-src`”恰恰是敌意文档不会对自己做的事。只有源边界能收住它。 +- **让 `/f` 跑在自己的回环端口上**——唯一能同时保住隔离与预览能力的选项,也是“独立的源”这个答案该有的形状。是暂缓而非否决:它需要一个带自身生命周期的第二监听器,外加把端口铺到客户端,那是另一个尺寸的改动。 +- **把路径在助手的收尾消息里链接化**——这是用户开口要的形状(“在结尾附上链接”),但它让渲染取决于模型是否把路径拼写得可识别。工具调用已经把 `locations` 作为结构化事实携带,产出文件行消费的正是它;因此把正文链接化是不必要,而不只是被推迟。 ## 影响 -现有的每一处文件交互都同时换了目标:write、edit、read 与通用单文件卡片都汇到 `openFile`,因此一处调用点的改动就让产出的文件在浏览器里可打开,LAN 客户端也在内。三个断言旧 `openPath` 去向的测试被改写为新的去向;工作区外的回退保留了旧断言。这条路由对着真实 HTTP 服务器与真实临时工作区做覆盖,因为收敛、内容定型与 sandbox 头都是协议事实;而组装后的 web 通道(`apps/web/tests/workspace-file-open.e2e.ts`,在冷播种会话上无密钥运行)证明了产品路径:点击读取行的路径会在第二个标签页打开 `/f//a.txt` 并提供那个工作区文件,而穿越写法应答 404。预览保有自身的能力,因此把主题持久化到 `localStorage` 的生成页面,按其作者的意图正常工作。仍然暂缓:由 `locations` 推导的回合末交付物行,以及助手 Markdown 内部的任何链接化。 +现有的每一处文件交互都同时换了目标:write、edit、read 与通用单文件卡片都汇到 `openFile`,因此一处调用点的改动就让产出的文件在浏览器里可打开,LAN 客户端也在内。三个断言旧 `openPath` 去向的测试被改写为新的去向;工作区外的回退保留了旧断言。这条路由对着真实 HTTP 服务器与真实临时工作区做覆盖,因为收敛、内容定型与 sandbox 头都是协议事实;而组装后的 web 通道(`apps/web/tests/workspace-file-open.e2e.ts`,在冷播种会话上无密钥运行)证明了产品路径:点击读取行的路径会在第二个标签页打开 `/f//a.txt` 并提供那个工作区文件,而穿越写法应答 404。预览在没有 `localStorage` 与 cookie 的情况下运行,这在会持久化主题的生成页面上看得见——那些场景仍有 Host 打开器,而独立的服务源是退休这条限制的路。产出文件行也在本次一并落地:`turnDeliverables` 依据改写工具的渲染意图(diff 卡片,或 `kind` 为 `edit` 的 generic 卡片)读出一轮的产出,在 turn 边界重置以免中断的一轮溢进下一轮,并渲染在收尾 assistant 之下。仍然暂缓:助手 Markdown 内部的链接化,以及任何跨会话回看既往产物的视图。 diff --git a/apps/web/tests/workspace-file-open.e2e.ts b/apps/web/tests/workspace-file-open.e2e.ts index 63d4266cf4..98d239beb1 100644 --- a/apps/web/tests/workspace-file-open.e2e.ts +++ b/apps/web/tests/workspace-file-open.e2e.ts @@ -1,29 +1,31 @@ -// Web e2e scenario: clicking a tool row's file path opens that file in a new -// browser tab, served by the web transport's own /f route. Cold-seeds the -// seeded-history fixture (zero model calls). The surface package tests can -// assert which opener the click reaches, but only the assembled application -// proves the opened URL actually serves the workspace file — the whole point -// of the route (docs/testing.md snapshot rule). -import { mkdir, readFile, writeFile } from 'node:fs/promises' +// Web e2e scenario: a produced file, from the row that lists it to the bytes +// the browser gets. Cold-seeds a recorded write turn (zero model calls). +// Package tests cover the derivation and the route in isolation, but only the +// assembled application shows that the turn's Produced row, the URL it opens, +// and the file on disk are the same thing (docs/testing.md snapshot rule). +import { readFile, writeFile, mkdir } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { - fixtureUserPrompts, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, + launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { newEnglishPage, saveFailureShot } from './support.ts' -// Borrowed read-only: this scenario needs any settled turn whose tool rows -// carry a workspace file path, not a new recording (message-actions pattern). -const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url)) +// Borrowed read-only: this scenario needs any settled turn whose tools WROTE a +// file, not a new recording (the message-actions borrowing pattern). +const SEED = fileURLToPath(new URL('./snapshots/permission-policy-context/session.jsonl', import.meta.url)) const MODE = webSnapshotMode() const SEED_ID = 'workspace-file-open-web-e2e' -const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.' +/** The file the borrowed recording's write tool produces. */ +const PRODUCED = 'policy-neutral.txt' +/** An active document placed alongside it, for the isolation header the route puts on those. */ +const ACTIVE = 'preview.html' -describe('web e2e: opening a workspace file from a tool row', () => { +describe('web e2e: opening a produced file from the conversation', () => { let scaffold: WebScaffold let browser: Browser let page: Page @@ -31,15 +33,13 @@ describe('web e2e: opening a workspace file from a tool row', () => { beforeAll(async () => { scaffold = await launchWebScaffold({}) - // The seeded Session's cwd is the scaffold workspace itself; the recording's - // own nested directory is written too, so the seed's paths stay resolvable. + // The seeded Session's cwd is the scaffold workspace; the recording's own + // nested directory is created too, so its paths stay resolvable. await mkdir(join(scaffold.workspaceCwd, 'workspace'), { recursive: true }) - for (const dir of [scaffold.workspaceCwd, join(scaffold.workspaceCwd, 'workspace')]) { - await writeFile(join(dir, 'a.txt'), 'alpha\n') - await writeFile(join(dir, 'b.txt'), 'beta\n') - } + await writeFile(join(scaffold.workspaceCwd, PRODUCED), 'neutral\n') + await writeFile(join(scaffold.workspaceCwd, ACTIVE), '

produced

\n') const raw = await readFile(SEED, 'utf8') - expect(fixtureUserPrompts(raw), 'borrowed seed must carry the drive prompt').toEqual([PROMPT]) + expect(raw, 'borrowed recording must carry the write this scenario reads').toContain(PRODUCED) await seedSession(scaffold, raw, SEED_ID) browser = await chromium.launch() page = await newEnglishPage(browser) @@ -53,7 +53,7 @@ describe('web e2e: opening a workspace file from a tool row', () => { await scaffold?.close() }) - it.skipIf(MODE === 'record')('opens the read row’s file in a new tab, served from the session workspace', async () => { + it.skipIf(MODE === 'record')('ends the turn with its produced file, which opens as the workspace file itself', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-workspace-file-open')) const groupRow = page.locator('[role="treeitem"]').first() await groupRow.waitFor({ timeout: 15_000 }) @@ -61,26 +61,32 @@ describe('web e2e: opening a workspace file from a tool row', () => { const sessionRow = page.locator('[role="treeitem"]').nth(1) await sessionRow.waitFor({ timeout: 10_000 }) await sessionRow.click() - await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1) - // The row summary IS the link: a button whose label is the tool's path. - const fileLink = page.getByRole('button', { name: 'a.txt', exact: true }).first() - await fileLink.waitFor({ timeout: 10_000 }) + // The row the turn ends with — derived from the write call's locations, + // not from whatever the closing message happened to say. + const chip = page.getByRole('button', { name: `Open ${PRODUCED}`, exact: true }).first() + await chip.waitFor({ timeout: 15_000 }) + expect(await chip.innerText()).toBe(PRODUCED) + const [opened] = await Promise.all([ page.context().waitForEvent('page', { timeout: 15_000 }), - fileLink.click(), + chip.click(), ]) await opened.waitForLoadState('domcontentloaded') - expect(new URL(opened.url()).pathname).toBe(`/f/${SEED_ID}/a.txt`) - expect(await opened.locator('body').innerText()).toContain('alpha') + expect(new URL(opened.url()).pathname).toBe(`/f/${SEED_ID}/${PRODUCED}`) + expect(await opened.locator('body').innerText()).toContain('neutral') - // The served response is a workspace read, not a download, and never cached - // past the turn that produced it. const served = await page.request.get(opened.url()) expect(served.status()).toBe(200) expect(served.headers()['x-content-type-options']).toBe('nosniff') expect(served.headers()['cache-control']).toBe('no-store') + // A workspace file is not necessarily agent-authored, so an active document + // is served into an opaque origin rather than same-origin with /api. + const active = await page.request.get(`${scaffold.baseUrl}/f/${SEED_ID}/${ACTIVE}`) + expect(active.status()).toBe(200) + expect(active.headers()['content-security-policy']).toContain('sandbox') + // Nothing outside the Session's workspace is reachable through the route. const escape = await page.request.get(`${scaffold.baseUrl}/f/${SEED_ID}/..%2Fetc%2Fhosts`) expect(escape.status()).toBe(404) diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 452ffe81c8..68e7f42e21 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: ebc2dea2787268e1686eac24565c2433cb5f4b66 -README.zh.md: f5653e9e0e0aed3cbf7a9e6124668942f4dde44a +README.md: cc7070500645f407b46f0326bf74479a81c579d8 +README.zh.md: 600b4f19d3262cb2381ec9368bc92b93db183603 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index ebc2dea278..cc70705006 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -12,10 +12,12 @@ The node half guards every request under `/api` before bridging (`src/api-reques The node half also serves one file at a time out of a Session's workspace under `/f//`, so a produced deliverable is reachable from the page that reported it — an `http` page cannot follow a `file://` link, and a browser that is not on the Host machine has no such path anyway. The segments ride the URL rather than a query parameter so a served document's relative references resolve to its siblings. The request names a Session and the gateway names that Session's directory (`ApiProxy.workspaceRootOf`, which answers from a live agent's header or the persistence store and never resumes an agent to serve a file); this package reads the authority rather than the core services, because holding their host-side Context declarations would merge them over the browser runtime's own. The URL shape itself lives with the other browser-importable contract surfaces, in [`@deepseek-ai/dsh-host-apiproxy/api`](../../host/apiproxy/README.md), so the browser half that builds a URL and this half that parses one share a single encoding decision. Both the cwd and the resolved target go through `realpath` before comparison, so a symlink inside the workspace pointing out of it is refused by its target rather than its name; traversal spellings are refused earlier still, at parse time, before any filesystem call. Reads stream (no request buffers a file), answer `GET`/`HEAD` only, and carry `nosniff` with `no-store`. Extensions outside the served content-type table are typed `text/plain` rather than offered as a download, because a workspace read is a request to see a file. -A served document carries no isolation header and is same-origin with `/api`. That is a decision, not an omission: the only author of these files is the agent already holding this user's shell and filesystem, so a `Content-Security-Policy: sandbox` would sit behind a trust boundary it has already crossed while costing every preview its `localStorage` and cookies — a generated page that remembers a theme breaks under it. A deployment that serves `dsh web` beyond loopback should treat workspace content as trusted, which is already true of everything else its agent does. Isolating a preview becomes a real question when workspace content stops being the viewer's own; the answer then is a separate origin, not a header. The same trust fence gates this prefix, so a `trustedHosts` deployment serves workspace files exactly where it serves ordinary reads. +Documents that can execute script — `.html`, `.htm`, `.xhtml`, `.svg` — additionally carry `Content-Security-Policy: sandbox allow-scripts allow-popups allow-modals allow-forms`, which runs them in an opaque origin. A workspace file is not necessarily agent-authored: a read row makes every file in a cloned repository openable, so an active document served same-origin with `/api` would have its script pass the browser-trust fence into every method, the loopback-pinned settings and credential plane included. The cost is borne by the preview — `localStorage`, cookies, and same-origin `fetch` are unavailable inside it, so a generated page that remembers a theme will not — and `host.openPath` remains the full-capability way to open the same file on the Host machine. Restoring those capabilities without reopening the hole needs a separate origin, not a weaker header. The same trust fence gates this prefix, so a `trustedHosts` deployment serves workspace files exactly where it serves ordinary reads. ## Keyless fixture +The fixture carrier has no `/f` route, and `IWorkspaces.fileUrl` derives its URL in the browser regardless of carrier, so a file-path row clicked under `fixture=` opens a tab that 404s where the Host opener used to be a silent no-op. Fixture pages carry no file rows today; a fixture scenario that adds them should stub the derivation rather than teach the in-memory carrier to serve bytes. + Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival. Fixture content search preserves the production-facing `unicode61`-style case, diacritic, and token-phrase behavior and returns a match-centered snippet of at most 120 Unicode code points. ## Model Experience diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index f5653e9e0e..600b4f19d3 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -12,10 +12,12 @@ node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust node 半侧还会在 `/f//` 下逐个提供某个 Session 工作区里的文件,让产出的交付物能从报告它的那个页面直接抵达——`http` 页面无法跟随 `file://` 链接,而不在 Host 机器上的浏览器本来也没有那条路径。段落走 URL 而非查询参数,是为了让所服务文档的相对引用能解析到它的同级文件。请求指名一个 Session,由网关指名该 Session 的目录(`ApiProxy.workspaceRootOf`,它从活跃 agent 的 header 或持久化存储作答,绝不会为了提供一个文件而恢复 agent);本包读取这个权威来源而不去够核心服务,因为持有它们的 host 侧 Context 声明会把它们盖到浏览器运行时自己的声明之上。URL 形状本身与其余浏览器可导入的契约面放在一起,位于 [`@deepseek-ai/dsh-host-apiproxy/api`](../../host/apiproxy/README.md),因此构造 URL 的浏览器半侧与解析 URL 的这一半共享同一个编码决定。cwd 与解析出的目标在比较前都要过 `realpath`,因此工作区内指向工作区外的符号链接会因其目标而被拒绝,而不是因其名字;穿越写法拒得更早,在解析期、任何文件系统调用之前。读取是流式的(没有请求会把文件缓冲起来),只应答 `GET`/`HEAD`,并带上 `nosniff` 与 `no-store`。所服务的内容类型表之外的扩展名一律按 `text/plain` 定型而非作为下载给出,因为工作区读取本就是一个“让我看看这个文件”的请求。 -所服务的文档不带任何隔离头,与 `/api` 同源。这是一个决定,不是遗漏:这些文件的唯一作者,正是那个已经握着本用户 shell 与文件系统的 agent,因此 `Content-Security-Policy: sandbox` 只会立在一条它早已越过的信任边界之后,代价却是每个预览都失去 `localStorage` 与 cookie——一个会记住主题的生成页面在它之下就是坏的。把 `dsh web` 服务到回环之外的部署,应当把工作区内容按可信处理,而这一点对其 agent 所做的其他一切本来就已成立。当工作区内容不再属于观看者本人时,隔离预览才成为一个真问题;那时的答案是一个独立的源,而不是一个头。这条前缀由同一道信任 fence 把守,因此配置了 `trustedHosts` 的部署提供工作区文件的范围,与它提供普通读取的范围完全一致。 +能执行脚本的文档——`.html`、`.htm`、`.xhtml`、`.svg`——还会额外带上 `Content-Security-Policy: sandbox allow-scripts allow-popups allow-modals allow-forms`,让它们运行在不透明源中。工作区文件未必由 agent 撰写:一条 read 行就能让 clone 下来的仓库里任何文件变得可打开,因此与 `/api` 同源提供的活动文档,其脚本会带着浏览器信任 fence 通行到每一个方法,包括那些正因会改动设置与凭据而被钉在回环的方法。代价由预览承担——其中无法使用 `localStorage`、cookie 与同源 `fetch`,因此一个会记住主题的生成页面在预览里记不住——而 `host.openPath` 仍是在 Host 机器上以完整能力打开同一文件的方式。要在不重新打开这个洞的前提下取回那些能力,需要的是一个独立的源,而不是一个更弱的头。这条前缀由同一道信任 fence 把守,因此配置了 `trustedHosts` 的部署提供工作区文件的范围,与它提供普通读取的范围完全一致。 ## 无密钥 fixture +fixture 载体没有 `/f` 路由,而 `IWorkspaces.fileUrl` 无论载体为何都在浏览器侧推导 URL,因此在 `fixture=` 下点击文件路径行会打开一个 404 的标签页,而此处从前是 Host 打开器的静默空操作。今天的 fixture 页面并不含文件行;若某个 fixture 场景要加上它们,应当把这段推导打桩,而不是教这个内存载体去提供字节。 + 任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session;`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace;`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId,均具有足够的确定性,组装后的 Web 测试可以据此协调列表与帧的到达。fixture 内容搜索会保留面向生产环境的 `unicode61` 式大小写、变音符号和 token/短语行为,并返回以匹配位置为中心、最多包含 120 个 Unicode 码点的 snippet。 ## 模型体验 diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 4f9ce7d51b..59bab263ea 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -118,7 +118,8 @@ export function apply(ctx: Context, config?: ConnectionConfig): void { return } if (req.method !== 'GET' && req.method !== 'HEAD') { - res.writeHead(405) + // RFC 9110 §15.5.6: a 405 names the methods the resource does support. + res.writeHead(405, { allow: 'GET, HEAD' }) res.end() return } diff --git a/packages/client/connection/src/workspace-files.ts b/packages/client/connection/src/workspace-files.ts index 354b7b2225..e173c33a40 100644 --- a/packages/client/connection/src/workspace-files.ts +++ b/packages/client/connection/src/workspace-files.ts @@ -10,13 +10,13 @@ * owns the browser-trust fence ([api-request-trust](./api-request-trust.ts)) — * this module is reached only by requests that already passed it. * - * A served document is same-origin with `/api`, and deliberately carries no - * isolation header. The only author of these files is the agent already - * holding this user's shell and filesystem, so a browser sandbox would not - * move the trust boundary — it would sit behind one already crossed, at the - * cost of `localStorage` and cookies in every preview. Isolating a preview - * becomes a real question when workspace content stops being the viewer's own; - * the answer then is a separate origin, not a header. + * Script-capable documents are served into an opaque origin. A workspace file + * is not necessarily agent-authored — a read row makes every file in a cloned + * repository openable — so an active document served same-origin with `/api` + * reaches the whole RPC surface, the loopback-pinned settings and credential + * methods included. The sandbox costs a preview its `localStorage` and + * cookies; restoring those without reopening that hole needs a separate + * origin, not a weaker header. */ import { createReadStream } from 'node:fs' @@ -59,6 +59,17 @@ const MIME: Record = { const DEFAULT_MIME = 'text/plain; charset=utf-8' +/** Extensions whose top-level navigation can execute script, and so need the sandbox. */ +const SCRIPTABLE = new Set(['.html', '.htm', '.xhtml', '.svg']) + +/** + * The opaque origin an active workspace document runs in. Without it the + * document is same-origin with `/api` and its script passes the browser-trust + * fence, which admits every method — including the ones pinned to loopback + * precisely because they mutate settings and credentials. + */ +const SANDBOX_CSP = 'sandbox allow-scripts allow-popups allow-modals allow-forms' + /** How the route learns which directory a session may serve from. */ export interface WorkspaceFileDeps { /** @@ -85,8 +96,11 @@ function fail(res: ServerResponse, status: number): void { */ async function confine(cwd: string, segments: readonly string[]): Promise { const root = await realpath(cwd) + // A filesystem root already ends in the separator; appending a second one + // would make every child fail the prefix test and 403 the whole workspace. + const prefix = root.endsWith(sep) ? root : root + sep const real = await realpath(resolve(root, ...segments)) - return real.startsWith(root + sep) ? real : undefined + return real.startsWith(prefix) ? real : undefined } /** @@ -146,6 +160,7 @@ export async function handleWorkspaceFile( // Workspace files change under the agent's hands; a cached preview would // show the previous turn's output after the next edit. 'cache-control': 'no-store', + ...SCRIPTABLE.has(ext) ? { 'content-security-policy': SANDBOX_CSP } : {}, }) if (req.method === 'HEAD') { res.end() diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 0b2c58ab37..8ab1fbce8e 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -34,11 +34,15 @@ function fakeRequest(headers: Record, url = `${API_PATH}/session } /** Response recorder compatible with both the fence's short-circuit and the bridge. */ -function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown } } { - const state: { status?: number; body?: unknown } = {} +function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown; headers?: Record } } { + const state: { status?: number; body?: unknown; headers?: Record } = {} const response = Object.assign(new EventEmitter(), { writableEnded: false, - writeHead(value: number) { state.status = value; return this }, + writeHead(value: number, headers?: Record) { + state.status = value + if (headers !== undefined) state.headers = headers + return this + }, write() { return true }, end(this: { writableEnded: boolean }, value?: unknown) { if (value !== undefined) state.body = value @@ -177,6 +181,7 @@ describe('connection node half: the /f workspace-file route', () => { Object.assign(post, { method: 'POST' }) await filesRoute(routes).handler(post, written.response) expect(written.state.status).toBe(405) + expect(written.state.headers).toMatchObject({ allow: 'GET, HEAD' }) await dispose() }) diff --git a/packages/client/connection/tests/workspace-files.spec.ts b/packages/client/connection/tests/workspace-files.spec.ts index eb36fdd6ea..6aee38e4d9 100644 --- a/packages/client/connection/tests/workspace-files.spec.ts +++ b/packages/client/connection/tests/workspace-files.spec.ts @@ -8,7 +8,7 @@ import type { AddressInfo } from 'node:net' import type { ServerResponse } from 'node:http' import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { join, sep } from 'node:path' import { Writable } from 'node:stream' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { FILES_PATH } from '@deepseek-ai/dsh-host-apiproxy/api' @@ -37,7 +37,8 @@ beforeAll(async () => { const server = createServer((req, res) => { void handleWorkspaceFile(req, res, { - cwdFor: async sessionId => sessionId === SESSION ? workspace : undefined, + // 'rooted' names the filesystem root, the separator-terminated realpath case. + cwdFor: async sessionId => sessionId === SESSION ? workspace : sessionId === 'rooted' ? sep : undefined, }) }) await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) @@ -58,25 +59,35 @@ function get(path: string, init?: RequestInit): Promise { } describe('workspace file reads', () => { - it('serves a produced document with its own capabilities intact', async () => { + it('serves an active document into an opaque origin', async () => { const response = await get(`${FILES_PATH}/${SESSION}/index.html`) expect(response.status).toBe(200) expect(await response.text()).toBe('

产物

') expect(response.headers.get('content-type')).toBe('text/html; charset=utf-8') - // No isolation header: a preview keeps localStorage and cookies, because - // the file's author already holds this user's shell (see the module doc). - expect(response.headers.get('content-security-policy')).toBeNull() + // A workspace file is not necessarily agent-authored, and same-origin + // script here would pass the browser-trust fence into every RPC method. + expect(response.headers.get('content-security-policy')).toContain('sandbox') + expect(response.headers.get('content-security-policy')).not.toContain('allow-same-origin') expect(response.headers.get('x-content-type-options')).toBe('nosniff') expect(response.headers.get('cache-control')).toBe('no-store') expect(response.headers.get('content-disposition')).toBe('inline') }) - it('types SVG as a standalone document rather than sniffable bytes', async () => { + it('sandboxes SVG too, and leaves inert types unrestricted', async () => { const svg = await get(`${FILES_PATH}/${SESSION}/chart.svg`) expect(svg.headers.get('content-type')).toBe('image/svg+xml') - expect(svg.headers.get('x-content-type-options')).toBe('nosniff') + expect(svg.headers.get('content-security-policy')).toContain('sandbox') const text = await get(`${FILES_PATH}/${SESSION}/notes.txt`) expect(text.headers.get('content-type')).toBe('text/plain; charset=utf-8') + expect(text.headers.get('content-security-policy')).toBeNull() + }) + + it('serves a workspace rooted at a filesystem root, whose realpath already ends in a separator', async () => { + // `realpath('/')` is '/', so a naive `root + sep` prefix is '//' and every + // child of that workspace would 403. + const rooted = await fetch(`${origin}${FILES_PATH}/rooted${new URL(`file://${workspace}/notes.txt`).pathname}`) + expect(rooted.status).toBe(200) + expect(await rooted.text()).toBe('plain') }) it('shows an unknown extension as text rather than downloading it', async () => { diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 505d693bb6..2a2857839d 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: b61a70fb079eb6a1bc2a67b682a337ffdf708b79 -README.zh.md: 0bb1740b166cfacc2bc79fe2f49793796f66c365 +README.md: ba55f0704500034b7afb37258064fe0801aaee91 +README.zh.md: 4212908b355a81dfd5af8645ce5d4284a4555622 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index b61a70fb07..ba55f07045 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -14,7 +14,7 @@ Approvals take over the composer through the chain this package declares: `Appro Logged non-user messages render as a default-collapsed `上下文注入` disclosure. It shares the Tool calls header geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap, shows inline JSON for both `content` and `source`, and synthesizes no tool state, summary, or keyed toolview dispatch ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)). -Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering. +Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file: one inside the session workspace opens in a new browser tab, served by the web transport's `/f` route, so a client that is not on the Host machine still sees it; one outside the workspace has no served URL and falls back to the Host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering. A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)). @@ -42,6 +42,8 @@ The chat stats line takes its token accounting from two generic token-meter proj `src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath). +A finished turn ends with the files it produced. `chat-flow.ts`'s `turnDeliverables` reads them off the mutation tools' own follow-along `locations` — a diff card, or a generic card whose `kind` is `edit` (the shape `str_replace_editor`'s insert presents) — so a turn's output is listed whether or not the closing message named it, and a new mutation tool joins by declaring what it does rather than by being added to a list. Reads, deletes, and failed calls contribute nothing; a path appears once per turn in first-seen order; accumulation resets on the turn boundary, so a turn that mutates and then ends without content text cannot spill into the next turn's row. The row renders under the closing assistant's body and above its IconActions, keyed to the same seq `assistantActionsSeqs` elects. It shows six chips (basename, full path as the title) plus an explicit remainder count, and each chip opens through the same `openFile` the tool rows use. + ## Model Experience None, as the conversation UI renders session history and streams in the browser; nothing here reaches a model request. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 0bb1740b16..4212908b35 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -12,7 +12,7 @@ 已记录的非用户消息渲染为默认折叠的 `上下文注入` 展开项。它通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 标题栏的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,并以内联 JSON 展示 `content` 和 `source`,且不会合成工具状态、摘要或键控 toolview 分发([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md))。 -通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。 +通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击即打开文件:位于会话工作区之内的文件在新浏览器标签页打开,由 web 传输的 `/f` 路由提供,因此不在 Host 机器上的客户端也能看到;工作区之外的文件没有可服务的 URL,回退到宿主操作系统的默认应用(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。 声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出按渲染意图开放——终端卡片与 web 卡片,各有自己的上限;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。 @@ -42,6 +42,8 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 `src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props,包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。 +完成的一轮以它产出的文件收尾。`chat-flow.ts` 的 `turnDeliverables` 从改写工具自身的跟随文件 `locations` 中读出它们——diff 卡片,或 `kind` 为 `edit` 的 generic 卡片(即 `str_replace_editor` 的 insert 所呈现的形状)——因此无论收尾消息是否点名,这一轮的产出都会被列出;新的改写工具靠声明自己做了什么加入,而不是靠被加进某张名单。read、删除与失败的调用不贡献任何条目;同一路径在一轮内按首见顺序只出现一次;累积在 turn 边界重置,因此一轮若先改写文件、随后没有正文内容就结束,不会溢进下一轮的行里。该行渲染在收尾 assistant 正文之下、其 IconActions 之上,键控到 `assistantActionsSeqs` 选出的同一个 seq。它展示六枚 chip(文本为文件名,完整路径作为 title),外加一个显式的剩余计数,每枚 chip 都经由工具行所用的同一个 `openFile` 打开。 + ## 模型体验 无。会话 UI 在浏览器中渲染会话历史与流;这里没有任何内容进入模型请求。 diff --git a/packages/client/ui-conversation/src/client/chat/chat-flow.ts b/packages/client/ui-conversation/src/client/chat/chat-flow.ts index 83ba5c463c..7b5c53d7ac 100644 --- a/packages/client/ui-conversation/src/client/chat/chat-flow.ts +++ b/packages/client/ui-conversation/src/client/chat/chat-flow.ts @@ -32,6 +32,21 @@ function rendersNothing(node: ConversationNode): boolean { || ((b.kind === 'text' || b.kind === 'reasoning') && b.text.trim() === '')) } +/** + * Paths a call view reports having created or changed, by render intent rather + * than tool name: a diff card, or a generic card whose kind is `edit` (the + * shape `str_replace_editor`'s insert presents). Every other card produces + * nothing to open — a read looked, a delete removed, a terminal ran. + */ +function producedPaths(view: ToolResultNode['callView']): readonly string[] { + if (view === null) return [] + if (view.card === 'diff') return (view.locations ?? []).map(location => location.path) + if (view.card === 'generic' && view.kind === 'edit') { + return (view.locations ?? []).map(location => location.path) + } + return [] +} + /** * Seq set of assistants that own IconActions: the last content-text assistant * in each turn. Mid-turn narration (text before tools) stays chrome-free. @@ -54,10 +69,19 @@ export function assistantActionsSeqs(nodes: readonly ConversationNode[]): Readon * * The source is the mutation tools' own follow-along `locations`, not the * closing prose: a produced file must be listed whether or not the model - * remembered to name it. Reads contribute nothing (looking at a file does not - * produce it) and a failed mutation contributes nothing (there is no file to - * open). Paths keep first-seen order and appear once, so a file written and - * then edited in the same turn is one entry. + * remembered to name it. A mutation is recognized by render intent, not by + * tool name — a diff card, or a generic card whose `kind` is `edit` (the shape + * `str_replace_editor`'s insert presents) — so a new mutation tool joins by + * declaring what it does. Reads contribute nothing (looking at a file does not + * produce it), and neither do deletes (there is nothing left to open) or + * failed calls. Paths keep first-seen order and appear once, so a file written + * and then edited in the same turn is one entry. + * + * Accumulation resets on the turn boundary, not merely at the closing + * assistant: a turn that mutates files and then ends without content text + * (interrupted mid-tool, or a turn whose last text precedes its last tool + * result) must not spill its paths into the next turn's row, nor leave `seen` + * suppressing a file the next turn legitimately rewrites. * @param nodes - snapshot nodes (surface order). * @returns Per-closing-seq produced paths; a turn that produced none is absent. */ @@ -65,21 +89,37 @@ export function turnDeliverables(nodes: readonly ConversationNode[]): ReadonlyMa const closing = assistantActionsSeqs(nodes) const byClosingSeq = new Map() let pending: string[] = [] - const seen = new Set() + let seen = new Set() + let turn: number | undefined for (const node of nodes) { if (node.kind === 'tool-result') { - if (node.isError || node.callView?.card !== 'diff') continue - for (const location of node.callView.locations ?? []) { - if (seen.has(location.path)) continue - seen.add(location.path) - pending.push(location.path) + if (node.isError) continue + for (const path of producedPaths(node.callView)) { + if (seen.has(path)) continue + seen.add(path) + pending.push(path) } continue } + // Tool results carry no turn of their own, so the boundary is read off the + // nodes that do. A user message opens a turn without reporting a number, + // which is why the tracked turn goes back to undefined there: the next + // node to report one is stating the current turn, not entering a new one. + if (node.kind === 'user') { + turn = undefined + pending = [] + seen = new Set() + } else if ('turn' in node) { + if (turn !== undefined && node.turn !== turn) { + pending = [] + seen = new Set() + } + turn = node.turn + } if (node.kind !== 'assistant' || !closing.has(node.seq)) continue if (pending.length > 0) byClosingSeq.set(node.seq, pending) pending = [] - seen.clear() + seen = new Set() } return byClosingSeq } diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 6f855231ac..d95d1db9ff 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -241,6 +241,38 @@ describe('chat-flow derivation', () => { expect(turnDeliverables([user(1, 'hi'), assistant(2, 'hello', 1)]).size).toBe(0) }) + it('turnDeliverables counts a generic edit and never spills across the turn boundary', () => { + const inserted = (seq: number, callId: string, path: string): ToolResultNode => ({ + ...toolResult(seq, callId, 'str_replace_editor'), + // str_replace_editor's insert mutates behind a generic card, so the + // discriminant is the render intent, not the card shape alone. + callView: { card: 'generic', title: `insert ${path}`, kind: 'edit', locations: [{ path }] }, + }) + const wrote = (seq: number, callId: string, path: string): ToolResultNode => ({ + ...toolResult(seq, callId, 'write'), + callView: { + card: 'diff', title: 'Write', diffs: [{ path, oldText: null, newText: 'x' }], locations: [{ path }], + }, + }) + const produced = turnDeliverables([ + user(1, 'insert a line'), + inserted(2, 'i', 'notes.md'), + assistant(3, 'inserted', 1), + // Turn 2 mutates and then ends with no content text (interrupted, or its + // last text preceded the tool): its paths must not ride into turn 3. + user(4, 'now rewrite it'), + wrote(5, 'w', 'leaked.txt'), + user(6, 'and again'), + wrote(7, 'w2', 'notes.md'), + assistant(8, 'done', 3), + ]) + expect(produced.get(3)).toEqual(['notes.md']) + // Turn 3 lists only its own file — and `seen` did not suppress the rewrite + // of a path an earlier turn already touched. + expect(produced.get(8)).toEqual(['notes.md']) + expect([...produced.values()].flat()).not.toContain('leaked.txt') + }) + it('renders the produced files under the closing message and opens one on click', () => { const wrote = (seq: number, callId: string, ...paths: string[]): ToolResultNode => ({ ...toolResult(seq, callId, 'write'), diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 3c20785657..63e1f0007c 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -2299,6 +2299,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro if (live !== undefined) return live.session.header.cwd const persistence = ctx.get('sessionPersistence') if (persistence === undefined) return undefined + // TODO(persistence/by-id): a full listing per lookup. Harmless while the + // caller is one preview open, but a served document with N relative + // sub-resources pays it N times; a by-id header read on the persistence + // seam would retire it. return (await persistence.list()).find(meta => meta.id === sessionId)?.cwd }, } From 59bfe77fb821eeadcea4b97cbb50981d04b556bd Mon Sep 17 00:00:00 2001 From: ZiyaZhang Date: Sat, 1 Aug 2026 02:17:25 -0700 Subject: [PATCH 008/104] feat(web): serve workspace files from their own origin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sandbox header bought isolation by taking the document's origin away, and measuring that cost decided against it: the reported artifact throws SecurityError on load, and because an uncaught exception aborts the rest of its ` + const head = html.indexOf('') + if (head !== -1) return `${html.slice(0, head + 6)}${script}${html.slice(head + 6)}` + /* v8 ignore next -- headless fixture pages may lack ; prepending keeps read-before-shell ordering. */ + return `${script}${html}` +} diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 59bab263ea..f0a60bbfb9 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -4,14 +4,13 @@ import z from 'schemastery' // Activates the httpServer Context merge used below. import type { WebRoute } from '@deepseek-ai/dsh-host-webserver' import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' -import { FILES_PATH } from '@deepseek-ai/dsh-host-apiproxy/api' // The merge-free types subpath: pulling the session package's root into this // client-registered program would merge the host `sessions` service over the // browser runtime's own. import type { SessionId } from '@deepseek-ai/dsh-session/types' import { API_PATH } from './api-path.ts' import { bridge } from './http-bridge.ts' -import { handleWorkspaceFile } from './workspace-files.ts' +import { injectFilesPort, listenForWorkspaceFiles } from './files-server.ts' import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts' export { API_PATH } from './api-path.ts' @@ -74,8 +73,10 @@ const PRIVILEGED_METHODS = new Set([ * additionally pass it with an empty trust list, which pins them to loopback. * @param ctx - Host plugin context. * @param config - resolved plugin config (schema defaults applied). + * @returns a promise settling once the workspace-file listener is bound and + * its port published — the page must never render before it can address one. */ -export function apply(ctx: Context, config?: ConnectionConfig): void { +export async function apply(ctx: Context, config?: ConnectionConfig): Promise { // The Loader resolves schema defaults; hand-built test contexts may pass none. const trustedHosts = config?.trustedHosts ?? [] // Config boundary: a malformed entry fails the load loudly here rather than @@ -108,23 +109,19 @@ export function apply(ctx: Context, config?: ConnectionConfig): void { // would merge their host-side Context declarations into the browser lane. const cwdFor = (sessionId: string): Promise => ctx.apiProxy.workspaceRootOf(sessionId as SessionId) - const filesRoute: WebRoute = { - kind: 'prefix', - path: FILES_PATH, - handler: async (req, res) => { - if (!isTrustedApiRequest(req, trustedHosts)) { - res.writeHead(403) - res.end('forbidden') - return - } - if (req.method !== 'GET' && req.method !== 'HEAD') { - // RFC 9110 §15.5.6: a 405 names the methods the resource does support. - res.writeHead(405, { allow: 'GET, HEAD' }) - res.end() - return - } - await handleWorkspaceFile(req, res, { cwdFor }) - }, - } - ctx.effect(() => ctx.httpServer.register(filesRoute), 'client-connection: /f route') + // Workspace files get their own port, and therefore their own origin: an + // active document served beside `/api` would reach every method through the + // fence below. The listen is awaited inside the effect so the port is known + // before the index tap that publishes it can run. + await ctx.effect(async () => { + const files = await listenForWorkspaceFiles( + ctx.httpServer.host, trustedHosts, { cwdFor }, + (error) => { ctx.logger.error(error) }, + ) + const untap = ctx.httpServer.tapIndex(html => injectFilesPort(html, files.port)) + return async () => { + untap() + await files.close() + } + }, 'client-connection: /f listener') } diff --git a/packages/client/connection/src/workspace-files.ts b/packages/client/connection/src/workspace-files.ts index e173c33a40..c934b14516 100644 --- a/packages/client/connection/src/workspace-files.ts +++ b/packages/client/connection/src/workspace-files.ts @@ -10,13 +10,11 @@ * owns the browser-trust fence ([api-request-trust](./api-request-trust.ts)) — * this module is reached only by requests that already passed it. * - * Script-capable documents are served into an opaque origin. A workspace file - * is not necessarily agent-authored — a read row makes every file in a cloned - * repository openable — so an active document served same-origin with `/api` - * reaches the whole RPC surface, the loopback-pinned settings and credential - * methods included. The sandbox costs a preview its `localStorage` and - * cookies; restoring those without reopening that hole needs a separate - * origin, not a weaker header. + * Isolation is the listener's, not this module's: these responses carry no + * sandbox header because they are served from their own port, and therefore + * their own origin ([files-server](./files-server.ts)). A served document + * keeps `localStorage`, cookies, and its own `fetch`, while the API stays + * cross-origin to it. */ import { createReadStream } from 'node:fs' @@ -59,17 +57,6 @@ const MIME: Record = { const DEFAULT_MIME = 'text/plain; charset=utf-8' -/** Extensions whose top-level navigation can execute script, and so need the sandbox. */ -const SCRIPTABLE = new Set(['.html', '.htm', '.xhtml', '.svg']) - -/** - * The opaque origin an active workspace document runs in. Without it the - * document is same-origin with `/api` and its script passes the browser-trust - * fence, which admits every method — including the ones pinned to loopback - * precisely because they mutate settings and credentials. - */ -const SANDBOX_CSP = 'sandbox allow-scripts allow-popups allow-modals allow-forms' - /** How the route learns which directory a session may serve from. */ export interface WorkspaceFileDeps { /** @@ -160,7 +147,6 @@ export async function handleWorkspaceFile( // Workspace files change under the agent's hands; a cached preview would // show the previous turn's output after the next edit. 'cache-control': 'no-store', - ...SCRIPTABLE.has(ext) ? { 'content-security-policy': SANDBOX_CSP } : {}, }) if (req.method === 'HEAD') { res.end() diff --git a/packages/client/connection/tests/client-apply.spec.ts b/packages/client/connection/tests/client-apply.spec.ts index 6892dc7721..4b323182bb 100644 --- a/packages/client/connection/tests/client-apply.spec.ts +++ b/packages/client/connection/tests/client-apply.spec.ts @@ -8,10 +8,11 @@ import { apply, 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?: { search: string } } +type Win = { location?: { search: string; protocol?: string; hostname?: string }; __DSH_FILES_PORT__?: number } afterEach(() => { delete (globalThis as Win).location + delete (globalThis as Win).__DSH_FILES_PORT__ }) async function mount(): Promise { @@ -62,4 +63,28 @@ describe('connection client apply', () => { } expect(seen.some(u => u.includes('/api/'))).toBe(true) }) + + it('addresses a workspace file on the port the host published, and only inside the workspace', async () => { + const win = globalThis as Win + win.location = { search: '', protocol: 'http:', hostname: '192.168.1.5' } + win.__DSH_FILES_PORT__ = 4321 + const handle = await mount() + const session = 's-1' as never + // Same hostname the page was reached by — a LAN client must reach previews + // too — and the published port, which is what makes it another origin. + expect(handle.fileUrl(session, '/w/alpha', '/w/alpha/out/a b.html')) + .toBe('http://192.168.1.5:4321/f/s-1/out/a%20b.html') + // Outside the workspace there is nothing this transport may serve, which + // is the signal a caller falls back to openPath on. + expect(handle.fileUrl(session, '/w/alpha', '/etc/hosts')).toBeUndefined() + }) + + it('serves no file URL on a page no host published a port into', async () => { + const win = globalThis as Win + win.location = { search: '?fixture', protocol: 'http:', hostname: '127.0.0.1' } + const handle = await mount() + // The keyless fixture lane: no workspace-file origin exists, so the row + // falls back to the Host opener instead of opening a dead tab. + expect(handle.fileUrl('s-1' as never, '/w', 'a.txt')).toBeUndefined() + }) }) diff --git a/packages/client/connection/tests/files-server.spec.ts b/packages/client/connection/tests/files-server.spec.ts new file mode 100644 index 0000000000..4a2618709b --- /dev/null +++ b/packages/client/connection/tests/files-server.spec.ts @@ -0,0 +1,44 @@ +/** The workspace-file listener's own failure and publication paths. */ +import { describe, expect, it } from 'vitest' +import { FILES_PATH } from '@deepseek-ai/dsh-host-apiproxy/api' +import { injectFilesPort, listenForWorkspaceFiles } from '../src/files-server.ts' + +describe('workspace-file listener', () => { + it('answers 400 and reports the failure when the directory lookup throws', async () => { + const seen: Error[] = [] + const files = await listenForWorkspaceFiles( + '127.0.0.1', [], + { cwdFor: () => Promise.reject(new Error('store unavailable')) }, + (error) => { seen.push(error) }, + ) + try { + // A lookup failure is the host's problem, not a miss: it must not become + // an unhandled rejection, and it must not be reported as "not found". + const response = await fetch(`http://127.0.0.1:${String(files.port)}${FILES_PATH}/s-1/a.txt`) + expect(response.status).toBe(400) + expect(seen.map(error => error.message)).toEqual(['store unavailable']) + } finally { + await files.close() + } + }) + + it('closes idempotently and stops answering', async () => { + const files = await listenForWorkspaceFiles( + '127.0.0.1', [], { cwdFor: async () => undefined }, () => {}, + ) + const origin = `http://127.0.0.1:${String(files.port)}` + expect((await fetch(`${origin}${FILES_PATH}/s-1/a.txt`)).status).toBe(404) + await files.close() + await files.close() + await expect(fetch(`${origin}${FILES_PATH}/s-1/a.txt`)).rejects.toThrow() + }) +}) + +describe('injectFilesPort', () => { + it('publishes the port as the first script in head', () => { + const html = injectFilesPort('x', 4321) + expect(html).toContain('') + // Ahead of anything the shell might read it from. + expect(html.indexOf('__DSH_FILES_PORT__')).toBeLessThan(html.indexOf('')) + }) +}) diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 8ab1fbce8e..2561a0846f 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -15,14 +15,21 @@ import { FILES_PATH } from '@deepseek-ai/dsh-host-apiproxy/api' import { API_PATH, apply, inject } from '../src/index.ts' /** Structural httpServer fake: the plugin only touches register(). */ -function fakeHttpServer(routes: WebRoute[]): Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> { +function fakeHttpServer( + routes: WebRoute[], + taps: ((html: string) => string)[] = [], +): Pick<HttpServerService, 'register' | 'tapIndex' | 'port' | 'host'> { return { register(route) { routes.push(route) return () => { routes.splice(routes.indexOf(route), 1) } }, - tapIndex: () => () => {}, + tapIndex(transform) { + taps.push(transform) + return () => { taps.splice(taps.indexOf(transform), 1) } + }, port: 0, + host: '127.0.0.1', } } @@ -61,21 +68,39 @@ function fakeApiProxy(workspaces: Record<string, string> = {}): ApiProxy { async function mounted( config?: { trustedHosts?: string[] }, workspaces: Record<string, string> = {}, -): Promise<{ routes: WebRoute[]; dispose: () => Promise<void> }> { +): Promise<{ routes: WebRoute[]; taps: ((html: string) => string)[]; dispose: () => Promise<void> }> { const ctx = new Context() const routes: WebRoute[] = [] - ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService) + const taps: ((html: string) => string)[] = [] + ctx.provide('httpServer', fakeHttpServer(routes, taps) as HttpServerService) ctx.provide('apiProxy', fakeApiProxy(workspaces)) const fiber = ctx.plugin({ inject: [...inject], apply }, config) await fiber.await() - return { routes, dispose: () => fiber.dispose() } + return { routes, taps, dispose: () => fiber.dispose() } } -/** The /f route is registered after /api; both are prefix routes on the same server. */ -function filesRoute(routes: WebRoute[]): WebRoute { - const route = routes.find(candidate => candidate.path === FILES_PATH) - if (route === undefined) throw new Error('the /f route was not registered') - return route +/** One raw GET whose Host header is spoofed (fetch forbids setting it). */ +function statusWithHost(origin: string, path: string, host: string): Promise<number> { + const url = new URL(origin) + return new Promise((resolve, reject) => { + const request = httpRequest( + { host: url.hostname, port: url.port, path, method: 'GET', headers: { host } }, + (response) => { + response.resume() + response.on('end', () => { resolve(response.statusCode ?? 0) }) + }, + ) + request.on('error', reject) + request.end() + }) +} + +/** The workspace-file origin the node half published into the index page. */ +function filesOrigin(taps: ((html: string) => string)[]): string { + const html = taps.reduce((acc, tap) => tap(acc), '<head></head>') + const port = /__DSH_FILES_PORT__ = (\d+)/.exec(html)?.[1] + if (port === undefined) throw new Error(`no workspace-file port was published: ${html}`) + return `http://127.0.0.1:${port}` } describe('connection node half', () => { @@ -89,11 +114,19 @@ describe('connection node half', () => { expect(routes).toHaveLength(0) }) - it('registers both transport prefix routes and removes them with the fiber', async () => { - const { routes, dispose } = await mounted() - expect(routes).toMatchObject([{ kind: 'prefix', path: API_PATH }, { kind: 'prefix', path: FILES_PATH }]) + it('registers the /api route and publishes a separate workspace-file origin, both removed with the fiber', async () => { + const { routes, taps, dispose } = await mounted() + // The API keeps one prefix on the shared server; workspace files get a + // port of their own, which is the origin boundary between them. + expect(routes).toMatchObject([{ kind: 'prefix', path: API_PATH }]) + const origin = filesOrigin(taps) + expect(new URL(origin).port).not.toBe('') + expect((await fetch(`${origin}${FILES_PATH}/absent/x.txt`)).status).toBe(404) await dispose() expect(routes).toHaveLength(0) + expect(taps).toHaveLength(0) + // Disposal reaches quiescence: the socket is gone, not merely unrouted. + await expect(fetch(`${origin}${FILES_PATH}/absent/x.txt`)).rejects.toThrow() }) it('refuses an untrusted Host on any /api path before the bridge runs', async () => { @@ -154,7 +187,7 @@ describe('connection node half', () => { }) }) -describe('connection node half: the /f workspace-file route', () => { +describe('connection node half: the workspace-file origin', () => { /** A workspace holding one file, torn down with the returned disposer. */ async function workspace(): Promise<{ cwd: string; remove: () => Promise<void> }> { const cwd = await mkdtemp(join(tmpdir(), 'dsh-node-half-')) @@ -162,40 +195,35 @@ describe('connection node half: the /f workspace-file route', () => { return { cwd, remove: () => rm(cwd, { recursive: true, force: true }) } } - /** HEAD keeps the assertion on the route's decision, not on the byte stream. */ - function head(url: string, headers: Record<string, string> = { host: '127.0.0.1:3080' }): IncomingMessage { - const request = fakeRequest(headers, url) - Object.assign(request, { method: 'HEAD' }) - return request - } - - it('applies the same browser-trust fence as /api, and refuses writes', async () => { - const { routes, dispose } = await mounted() - const untrusted = fakeResponse() - await filesRoute(routes).handler(head(`${FILES_PATH}/s-1/index.html`, { host: 'harness.example' }), untrusted.response) - expect(untrusted.state.status).toBe(403) - expect(untrusted.state.body).toBe('forbidden') - - const written = fakeResponse() - const post = fakeRequest({ host: '127.0.0.1:3080' }, `${FILES_PATH}/s-1/index.html`) - Object.assign(post, { method: 'POST' }) - await filesRoute(routes).handler(post, written.response) - expect(written.state.status).toBe(405) - expect(written.state.headers).toMatchObject({ allow: 'GET, HEAD' }) + it('applies the same browser-trust fence as /api, refuses writes, and serves nothing else', async () => { + const { taps, dispose } = await mounted() + const origin = filesOrigin(taps) + // Rebound Host: refused before any filesystem work, exactly as on /api. + // node's fetch refuses to set Host (a forbidden header), so the spoof goes + // through the raw client — the same parse the server really performs. + expect(await statusWithHost(origin, `${FILES_PATH}/s-1/index.html`, 'harness.example')).toBe(403) + const written = await fetch(`${origin}${FILES_PATH}/s-1/index.html`, { method: 'POST' }) + expect(written.status).toBe(405) + expect(written.headers.get('allow')).toBe('GET, HEAD') + // This origin is one route wide: no index, no SPA fallback, no API. + expect((await fetch(`${origin}/`)).status).toBe(404) + expect((await fetch(`${origin}${API_PATH}/session.list`, { method: 'POST' })).status).toBe(404) await dispose() }) it('confines reads to the directory the gateway names for that session', async () => { const { cwd, remove } = await workspace() - const { routes, dispose } = await mounted(undefined, { 's-1': cwd }) - const served = fakeResponse() - await filesRoute(routes).handler(head(`${FILES_PATH}/s-1/index.html`), served.response) - expect(served.state.status).toBe(200) + const { taps, dispose } = await mounted(undefined, { 's-1': cwd }) + const origin = filesOrigin(taps) + const served = await fetch(`${origin}${FILES_PATH}/s-1/index.html`) + expect(served.status).toBe(200) + expect(await served.text()).toBe('<h1>ok</h1>') + // A served document keeps its own capabilities: the port is the boundary, + // so nothing here strips the document of its origin. + expect(served.headers.get('content-security-policy')).toBeNull() // A session the gateway names no directory for has no workspace to confine // against, so there is nothing to serve. - const unknown = fakeResponse() - await filesRoute(routes).handler(head(`${FILES_PATH}/s-absent/index.html`), unknown.response) - expect(unknown.state.status).toBe(404) + expect((await fetch(`${origin}${FILES_PATH}/s-absent/index.html`)).status).toBe(404) await dispose() await remove() }) diff --git a/packages/client/connection/tests/workspace-files.spec.ts b/packages/client/connection/tests/workspace-files.spec.ts index 6aee38e4d9..8e33a6751b 100644 --- a/packages/client/connection/tests/workspace-files.spec.ts +++ b/packages/client/connection/tests/workspace-files.spec.ts @@ -59,27 +59,25 @@ function get(path: string, init?: RequestInit): Promise<Response> { } describe('workspace file reads', () => { - it('serves an active document into an opaque origin', async () => { + it('serves a produced document with its own capabilities intact', async () => { const response = await get(`${FILES_PATH}/${SESSION}/index.html`) expect(response.status).toBe(200) expect(await response.text()).toBe('<h1>产物</h1>') expect(response.headers.get('content-type')).toBe('text/html; charset=utf-8') - // A workspace file is not necessarily agent-authored, and same-origin - // script here would pass the browser-trust fence into every RPC method. - expect(response.headers.get('content-security-policy')).toContain('sandbox') - expect(response.headers.get('content-security-policy')).not.toContain('allow-same-origin') + // No isolation header: the listener's own port is the origin boundary, so + // a preview keeps localStorage and cookies (see files-server). + expect(response.headers.get('content-security-policy')).toBeNull() expect(response.headers.get('x-content-type-options')).toBe('nosniff') expect(response.headers.get('cache-control')).toBe('no-store') expect(response.headers.get('content-disposition')).toBe('inline') }) - it('sandboxes SVG too, and leaves inert types unrestricted', async () => { + it('types SVG as a standalone document rather than sniffable bytes', async () => { const svg = await get(`${FILES_PATH}/${SESSION}/chart.svg`) expect(svg.headers.get('content-type')).toBe('image/svg+xml') - expect(svg.headers.get('content-security-policy')).toContain('sandbox') + expect(svg.headers.get('x-content-type-options')).toBe('nosniff') const text = await get(`${FILES_PATH}/${SESSION}/notes.txt`) expect(text.headers.get('content-type')).toBe('text/plain; charset=utf-8') - expect(text.headers.get('content-security-policy')).toBeNull() }) it('serves a workspace rooted at a filesystem root, whose realpath already ends in a separator', async () => { diff --git a/packages/client/runtime/src/client/contract/workspaces.ts b/packages/client/runtime/src/client/contract/workspaces.ts index dbc0f3b30f..3e64ef3717 100644 --- a/packages/client/runtime/src/client/contract/workspaces.ts +++ b/packages/client/runtime/src/client/contract/workspaces.ts @@ -56,17 +56,6 @@ export interface IWorkspaces { * @param path - absolute or host-resolvable path. */ openPath(path: string): Promise<void> - /** - * URL serving one file out of a session's workspace, for a UI that opens a - * produced file in the browser instead of on the Host machine. - * @param sessionId - the session whose cwd anchors the path. - * @param cwd - that session's working directory, or `undefined` when unknown. - * @param path - the path a tool reported (absolute, or relative to `cwd`). - * @returns the origin-relative URL, or `undefined` when the path lies - * outside the workspace — which this transport never serves, leaving - * {@link IWorkspaces.openPath} as the only way to reach it. - */ - fileUrl(sessionId: SessionId, cwd: string | undefined, path: string): string | undefined /** * Rename a Workspace. * @param workspaceId - target workspace. diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index 837a7daa03..c0eb46fcf9 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -5,7 +5,6 @@ import type { DirectoryListing, IApiClient, RpcError, SessionId, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-client-connection/client' -import { workspaceFileSegments, workspaceFileUrl } from '@deepseek-ai/dsh-host-apiproxy/api' import type { SnapshotStore } from '../contract/store.ts' import { createSnapshotStore } from '../contract/store.ts' import type { SessionsPort, SessionsPortList } from '../contract/sessions-port.ts' @@ -240,18 +239,6 @@ export class WorkspacesService implements IWorkspaces { } } - /** - * URL serving one file out of a session's workspace. - * @param sessionId - the session whose cwd anchors the path. - * @param cwd - that session's working directory, or `undefined` when unknown. - * @param path - the path a tool reported (absolute, or relative to `cwd`). - * @returns the origin-relative URL, or `undefined` for a path outside the workspace. - */ - fileUrl(sessionId: SessionId, cwd: string | undefined, path: string): string | undefined { - const segments = workspaceFileSegments(cwd, path) - if (segments === undefined) return undefined - return workspaceFileUrl(sessionId, segments) - } /** * Rename a Workspace. diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index d389efe319..a5827173a8 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -26,6 +26,7 @@ async function mount(): Promise<Bench> { const bench: Bench = { ctx, api, sinks: undefined, stopped: 0 } const handle: ConnectionHandle = { api, + fileUrl: () => undefined, start: (sinks) => { bench.sinks = sinks return { stop: () => { bench.stopped += 1 } } diff --git a/packages/client/runtime/tests/wire-events.spec.ts b/packages/client/runtime/tests/wire-events.spec.ts index fd7858d60c..a35983d890 100644 --- a/packages/client/runtime/tests/wire-events.spec.ts +++ b/packages/client/runtime/tests/wire-events.spec.ts @@ -20,6 +20,7 @@ async function mount(): Promise<Bench> { const bench: Bench = { ctx, sinks: undefined } const handle: ConnectionHandle = { api, + fileUrl: () => undefined, start: (sinks) => { bench.sinks = sinks return { stop: () => {} } diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index 3d9cef547f..4323d7ffce 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -276,21 +276,6 @@ describe('WorkspacesService', () => { await expect(workspaces.openPath('/missing')).rejects.toThrow(/path open failed/) }) - it('addresses a workspace file by URL, and only inside the workspace', async () => { - const ctx = new Context() - const api = new FakeApiClient() - const sessions = new SessionsService(ctx, api) - const workspaces = new WorkspacesService(ctx, api, sessions) - const session = 's-1' as SessionId - // The URL is derived, not fetched: no wire call answers a link. - expect(workspaces.fileUrl(session, '/w/alpha', '/w/alpha/out/a b.html')).toBe('/f/s-1/out/a%20b.html') - expect(workspaces.fileUrl(session, '/w/alpha', 'out/index.html')).toBe('/f/s-1/out/index.html') - // Outside the workspace there is nothing this transport may serve, which - // is the signal a caller falls back to openPath on. - expect(workspaces.fileUrl(session, '/w/alpha', '/etc/hosts')).toBeUndefined() - expect(api.calls).toHaveLength(0) - }) - it('deletes a Workspace or preserves it when the Host rejects deletion', async () => { const ctx = new Context() const api = new FakeApiClient() diff --git a/packages/client/test-runtime/package.json b/packages/client/test-runtime/package.json index e892d9cd52..6d7093a148 100644 --- a/packages/client/test-runtime/package.json +++ b/packages/client/test-runtime/package.json @@ -25,6 +25,7 @@ "vitest": "^4.1.8" }, "peerDependencies": { + "@deepseek-ai/dsh-client-connection": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-client-web-react": "^0.0.1", @@ -35,6 +36,7 @@ "react-dom": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-web-react": "workspace:^", diff --git a/packages/client/test-runtime/src/connection.ts b/packages/client/test-runtime/src/connection.ts new file mode 100644 index 0000000000..5df5d5a053 --- /dev/null +++ b/packages/client/test-runtime/src/connection.ts @@ -0,0 +1,48 @@ +/** Test-owned connection face: the transport members features read off `ctx.connection`. */ +import { workspaceFileSegments, workspaceFileUrl } from '@deepseek-ai/dsh-host-apiproxy/api' +import type { ConnectionHandle, IApiClient, SessionId } from '@deepseek-ai/dsh-client-connection/client' + +/** + * Connection test double. Implements the same `ConnectionHandle` face features + * receive as `ctx.connection`, so a production face change breaks this double + * at compile time. The wire client is not modelled — a feature that needs one + * composes its own connection over a fake api client; this double exists for + * the transport facts features read synchronously, above all the + * workspace-file URL. + */ +export class TestConnection implements ConnectionHandle { + /** + * The workspace-file port the host would have published into the page. + * Unset — the default, and the keyless fixture lane's real state — makes + * {@link TestConnection.fileUrl} answer `undefined`, which is the signal a + * caller falls back to the Host opener on. + */ + filesPort: number | undefined + + /** The wire client; unused by this double's consumers and absent by construction. */ + readonly api: IApiClient = undefined as unknown as IApiClient + + /** + * Stream-loop starter (inert). + * @returns a stop handle that does nothing. + */ + start(): { stop(): void } { + return { stop: () => {} } + } + + /** + * Workspace-file URL, deriving exactly as production does so a feature test + * sees the real inside/outside-workspace split. + * @param sessionId - the Session whose cwd anchors the path. + * @param cwd - that Session's working directory. + * @param path - the path a tool reported. + * @returns the absolute URL on the workspace-file origin, or undefined when + * the path leaves the workspace or no port is published. + */ + fileUrl(sessionId: SessionId, cwd: string | undefined, path: string): string | undefined { + if (this.filesPort === undefined) return undefined + const segments = workspaceFileSegments(cwd, path) + if (segments === undefined) return undefined + return `http://localhost:${String(this.filesPort)}${workspaceFileUrl(sessionId, segments)}` + } +} diff --git a/packages/client/test-runtime/src/index.ts b/packages/client/test-runtime/src/index.ts index 5ef5350434..cdbdca75a2 100644 --- a/packages/client/test-runtime/src/index.ts +++ b/packages/client/test-runtime/src/index.ts @@ -29,11 +29,13 @@ import type { } from '@deepseek-ai/dsh-client-ui-slots' import { registerDomSnapshotSerializer } from './snapshot.ts' import { TestSessions } from './sessions.ts' +import { TestConnection } from './connection.ts' import { TestWorkspaces } from './workspaces.ts' import type { Stabilizer } from './fixtures.ts' export { domSnapshotSerializer, registerDomSnapshotSerializer } from './snapshot.ts' export { FixtureSession, TestSessions } from './sessions.ts' +export { TestConnection } from './connection.ts' export { TestWorkspaces } from './workspaces.ts' export { conversationSnapshot, workspaceListState } from './fixtures.ts' export type { SessionBehaviorOverrides, SessionFixture, Stabilizer } from './fixtures.ts' @@ -175,6 +177,8 @@ export class SlotTestRuntime { readonly sessions: TestSessions /** Workspaces double (list observable, recorded intent actions). */ readonly workspaces: TestWorkspaces + /** The transport double features read as `ctx.connection`. */ + readonly connection: TestConnection private readonly stabilizer: Stabilizer = async (fn) => { await act(async () => { await fn() }) @@ -195,8 +199,10 @@ export class SlotTestRuntime { this.root = new TestRoot(slots, this.stabilizer) this.sessions = new TestSessions(this.stabilizer, ctx) this.workspaces = new TestWorkspaces(this.stabilizer) + this.connection = new TestConnection() ctx.provide('sessions', this.sessions) ctx.provide('workspaces', this.workspaces) + ctx.provide('connection', this.connection) // Capturing install: the production renderer does the rendering; the // wrapper only takes the host face for storeOf (no machinery copied). const renderer = createSlotRenderer() diff --git a/packages/client/test-runtime/src/workspaces.ts b/packages/client/test-runtime/src/workspaces.ts index 01e7db4c3d..95f6574405 100644 --- a/packages/client/test-runtime/src/workspaces.ts +++ b/packages/client/test-runtime/src/workspaces.ts @@ -1,6 +1,5 @@ /** Test-owned workspaces face: the renderer standard-kit observable plus recorded actions. */ import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' -import { workspaceFileSegments, workspaceFileUrl } from '@deepseek-ai/dsh-host-apiproxy/api' import type { DirectoryListing, IWorkspaces, SessionId, SnapshotStore, WorkspaceId, WorkspaceListState, WorkspaceView, } from '@deepseek-ai/dsh-client-runtime/client' @@ -99,21 +98,6 @@ export class TestWorkspaces implements IWorkspaces { await (this.stubs.get('openPath')?.(path) as Promise<void> | undefined) } - /** - * Workspace-file URL (recorded). Runs the production path derivation so a - * feature test sees the real in/outside-workspace split; stub to force either. - * @param sessionId - the session whose cwd anchors the path. - * @param cwd - that session's working directory. - * @param path - the path a tool reported. - * @returns the origin-relative URL, or undefined outside the workspace. - */ - fileUrl(sessionId: SessionId, cwd: string | undefined, path: string): string | undefined { - this.calls.push({ method: 'fileUrl', args: [sessionId, cwd, path] }) - const stub = this.stubs.get('fileUrl') - if (stub !== undefined) return stub(sessionId, cwd, path) as string | undefined - const segments = workspaceFileSegments(cwd, path) - return segments === undefined ? undefined : workspaceFileUrl(sessionId, segments) - } /** * Directory picker (recorded). The default cancels (null); stub to select. diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx index a9c4b0c9ca..3675671f26 100644 --- a/packages/client/test-runtime/tests/runtime.spec.tsx +++ b/packages/client/test-runtime/tests/runtime.spec.tsx @@ -549,10 +549,6 @@ describe('workspaces action face', () => { expect(renamed.title).toBe('Renamed') await ws.delete('w1' as WorkspaceId) await ws.openPath('/proj/file.ts') - // fileUrl runs the production derivation, so a feature test sees the same - // inside/outside-workspace split the browser half decides on. - expect(ws.fileUrl('s1' as SessionId, '/proj', 'out/a.html')).toBe('/f/s1/out/a.html') - expect(ws.fileUrl('s1' as SessionId, '/proj', '/etc/hosts')).toBeUndefined() const moved = await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId, 's2' as SessionId) expect(moved.sessionIds).toEqual(['s1']) // Default archive mirrors the production effect: the id joins the list @@ -560,15 +556,13 @@ describe('workspaces action face', () => { await ws.archiveSession('s1' as SessionId) expect(ws.list.getSnapshot().archivedSessionIds).toEqual(['s1']) expect(ws.calls.map(c => c.method)).toEqual( - ['create', 'create', 'pickDirectory', 'rename', 'delete', 'openPath', 'fileUrl', 'fileUrl', - 'insertSessionBefore', 'archiveSession']) + ['create', 'create', 'pickDirectory', 'rename', 'delete', 'openPath', 'insertSessionBefore', 'archiveSession']) ws.stub('create', () => Promise.resolve({ workspaceId: 'ws-x', title: 'X', path: '/x', sessionIds: [] } as never)) ws.stub('pickDirectory', () => Promise.resolve('/picked')) ws.stub('rename', () => Promise.resolve({ workspaceId: 'w1', title: 'S', path: '/s', sessionIds: [] } as never)) ws.stub('delete', () => Promise.resolve()) ws.stub('openPath', () => Promise.resolve()) - ws.stub('fileUrl', () => '/f/forced/a.html') ws.stub('insertSessionBefore', () => Promise.resolve({ workspaceId: 'w1', title: '', path: '', sessionIds: [] } as never)) ws.stub('archiveSession', () => Promise.resolve()) expect((await ws.create({ name: 'y' })).title).toBe('X') @@ -576,7 +570,6 @@ describe('workspaces action face', () => { expect((await ws.rename('w1' as WorkspaceId, 'z')).title).toBe('S') await ws.delete('w1' as WorkspaceId) await ws.openPath('/other') - expect(ws.fileUrl('s1' as SessionId, '/proj', '/etc/hosts')).toBe('/f/forced/a.html') expect((await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId)).sessionIds).toEqual([]) // The stub replaces the default set mutation: the set stays as-is. await ws.archiveSession('s2' as SessionId) diff --git a/packages/client/test-runtime/tsconfig.json b/packages/client/test-runtime/tsconfig.json index 6a758c66f9..681bff474c 100644 --- a/packages/client/test-runtime/tsconfig.json +++ b/packages/client/test-runtime/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../web-react" }, + { + "path": "../connection" + }, { "path": "../runtime" }, diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 2a2857839d..446c2084b1 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: ba55f0704500034b7afb37258064fe0801aaee91 -README.zh.md: 4212908b355a81dfd5af8645ce5d4284a4555622 +README.md: 8c2075d615eccad1bbc7f5de1255ea4add69fab8 +README.zh.md: 634721b4248da75cbd4e81528340936a31ece28d diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index ba55f07045..8c2075d615 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -14,7 +14,7 @@ Approvals take over the composer through the chain this package declares: `Appro Logged non-user messages render as a default-collapsed `上下文注入` disclosure. It shares the Tool calls header geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap, shows inline JSON for both `content` and `source`, and synthesizes no tool state, summary, or keyed toolview dispatch ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)). -Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file: one inside the session workspace opens in a new browser tab, served by the web transport's `/f` route, so a client that is not on the Host machine still sees it; one outside the workspace has no served URL and falls back to the Host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering. +Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file: one inside the session workspace opens in a new browser tab on the transport's workspace-file origin (`ConnectionHandle.fileUrl`), so a client that is not on the Host machine still sees it; one outside the workspace has no served URL and falls back to the Host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering. A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 4212908b35..634721b424 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -12,7 +12,7 @@ 已记录的非用户消息渲染为默认折叠的 `上下文注入` 展开项。它通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 标题栏的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,并以内联 JSON 展示 `content` 和 `source`,且不会合成工具状态、摘要或键控 toolview 分发([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md))。 -通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击即打开文件:位于会话工作区之内的文件在新浏览器标签页打开,由 web 传输的 `/f` 路由提供,因此不在 Host 机器上的客户端也能看到;工作区之外的文件没有可服务的 URL,回退到宿主操作系统的默认应用(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。 +通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击即打开文件:位于会话工作区之内的文件在新浏览器标签页打开,位于传输层的工作区文件源上(`ConnectionHandle.fileUrl`),因此不在 Host 机器上的客户端也能看到;工作区之外的文件没有可服务的 URL,回退到宿主操作系统的默认应用(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。 声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出按渲染意图开放——终端卡片与 web 卡片,各有自己的上限;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 88c09b5550..55036600c6 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -39,6 +39,7 @@ "clsx": "^2.0.0" }, "peerDependencies": { + "@deepseek-ai/dsh-client-connection": "^0.0.1", "@deepseek-ai/dsh-client-locale": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", @@ -50,6 +51,7 @@ "react": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 71f05267b3..a65ce1d7a4 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -2,6 +2,7 @@ import type { Context } from 'cordis' import { resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots' import type { ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' @@ -42,7 +43,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } /** Services required by the conversation plugin. */ -export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale'] +export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale', 'connection'] // Static no-session sources for the composer-bar hooks compartment: module // constants so the render side's per-source hook cache (observableHook) keeps @@ -275,11 +276,12 @@ export function apply(ctx: Context): void { }, openFile: (path) => { const cwd = sessions.list.getSnapshot().byId[sessionId]?.cwd - // A file inside the workspace opens in a new tab, so a browser that - // is not on the Host machine can still see what the agent produced. - // Anything outside it has no served URL and falls back to the Host's - // own opener, which is loopback-only by the /api trust fence. - const url = workspaces.fileUrl(sessionId, cwd, path) + // A file inside the workspace opens in a new tab on the transport's + // workspace-file origin, so a browser that is not on the Host machine + // can still see what the agent produced. Anything outside it has no + // served URL and falls back to the Host's own opener, which is + // loopback-only by the /api trust fence. + const url = (ctx.get('connection') as ConnectionHandle).fileUrl(sessionId, cwd, path) if (url !== undefined) { window.open(url, '_blank', 'noopener,noreferrer') return diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 6427f6750c..b9dbe0d6ad 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -220,12 +220,15 @@ describe('conversation slot inject surface', () => { it('openFile (chat view face) opens a workspace file in a tab and falls back to the host opener outside it', async () => { const b = await bench() + // A host that publishes a workspace-file port: previews come from that + // origin, which is what keeps them off the API's. + b.runtime.connection.filesPort = 4321 const open = vi.spyOn(window, 'open').mockReturnValue(null) const { injected } = b.chatViewSurface(ROOT) - // Inside the session cwd: served by this origin, so a browser anywhere on - // the network sees the file the agent produced. + // Inside the session cwd: served on the workspace-file origin, so a browser + // anywhere on the network sees the file the agent produced. injected.openFile('src/a.ts') - expect(open).toHaveBeenCalledWith(`/f/${ROOT}/src/a.ts`, '_blank', 'noopener,noreferrer') + expect(open).toHaveBeenCalledWith(`http://localhost:4321/f/${ROOT}/src/a.ts`, '_blank', 'noopener,noreferrer') expect(b.runtime.workspaces.calls.some(c => c.method === 'openPath')).toBe(false) // Outside it there is no served URL, so the Host's own opener answers — // resolved against the session cwd exactly as before. diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index 51e31c9750..2763702c0b 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -134,9 +134,11 @@ async function bench(snapshot: ConversationSnapshot) { startSession: vi.fn(), sendSession: vi.fn(), openPath: vi.fn(async () => {}), - fileUrl: vi.fn((_sessionId: unknown, _cwd: string | undefined, path: string) => `/f/s-1/${path}`), } ctx.provide('workspaces', workspaces) + // The transport face the chat view reads its workspace-file URLs from. + const connection = { fileUrl: vi.fn((_s: unknown, _cwd: string | undefined, path: string) => `http://localhost:4321/f/s-1/${path}`) } + ctx.provide('connection', connection) ctx.provide('layout', layout) const locale = new LocaleService(ctx) ctx.provide('locale', locale) @@ -249,7 +251,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(open).toHaveBeenCalledWith('/f/s-1/notes/demo.txt', '_blank', 'noopener,noreferrer') + expect(open).toHaveBeenCalledWith('http://localhost:4321/f/s-1/notes/demo.txt', '_blank', 'noopener,noreferrer') }) open.mockRestore() view.getByText('List notes').click() diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index 6cb46e7ea0..84cdd53eeb 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -121,6 +121,7 @@ describe('keyed toolview hole through the real machinery', () => { it('file-path clicks travel owner openFile → chat inject → the served workspace URL', async () => { const b = await bench([toolResult(3, 'c1', 'read', '{"path":"src/a.ts"}')]) + b.runtime.connection.filesPort = 4321 const open = vi.spyOn(window, 'open').mockReturnValue(null) const view = b.runtime.renderRoot() view.getByText('src/a.ts').click() diff --git a/packages/client/ui-conversation/tsconfig.json b/packages/client/ui-conversation/tsconfig.json index 04b265bdd5..33d45124b4 100644 --- a/packages/client/ui-conversation/tsconfig.json +++ b/packages/client/ui-conversation/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../web-react" }, + { + "path": "../connection" + }, { "path": "../runtime" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7073f04e4b..5fb7df52ad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1183,6 +1183,9 @@ importers: specifier: ^4.1.8 version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) devDependencies: + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime @@ -1263,6 +1266,9 @@ importers: specifier: ^2.0.0 version: 2.1.1 devDependencies: + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale From 8fb6c2bd698d75a621912570663505d4dd0d42e4 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <zzy211851@gmail.com> Date: Sat, 1 Aug 2026 03:15:54 -0700 Subject: [PATCH 009/104] refactor(web): open produced files through the Host, not over HTTP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scope decision: previews for a browser that is not on the Host machine are not supported. With that settled, host.openPath answers the supported case completely — a file:// document in a real browser has full page capabilities and no reach into /api — and the HTTP serving this branch had built answered only the unsupported one. Removed: the /f route and its listener, the workspace-file URL shape, ApiProxy.workspaceRootOf, ConnectionHandle.fileUrl, and the port published into the index page. Kept, and finished: - the produced-files row a turn ends with, derived from mutation locations; - the path link now reads as a link at rest, not only on hover — the reported "I can't open what it made" was this, sitting on a working capability; - the Host opener prefers the default BROWSER for .html/.htm/.xhtml/.svg, so a developer who binds .html to an editor still gets a rendered page (macOS via the LaunchServices https handler, Linux via $BROWSER, every failure falling back to the default application). The retired designs and their measurements stay in the Agent Note, including why same-origin serving was unsafe and why the sandbox that fixed it broke the pages invisibly. --- ...6-07-31-web-workspace-file-links.i18n.yaml | 4 +- .../2026-07-31-web-workspace-file-links.md | 28 ++- .../2026-07-31-web-workspace-file-links.zh.md | 28 ++- apps/web/tests/produced-files.e2e.ts | 76 ++++++++ apps/web/tests/workspace-file-open.e2e.ts | 122 ------------- apps/web/tsconfig.json | 2 +- docs/config-catalog.md | 2 +- packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 10 +- packages/client/connection/README.zh.md | 8 +- .../client/connection/src/client/fixture.ts | 4 - .../client/connection/src/client/index.ts | 25 --- .../client/connection/src/files-server.ts | 127 -------------- packages/client/connection/src/index.ts | 41 +---- .../client/connection/src/workspace-files.ts | 164 ------------------ .../connection/tests/client-apply.spec.ts | 26 +-- .../connection/tests/files-server.spec.ts | 44 ----- .../client/connection/tests/node-half.spec.ts | 114 ++---------- .../connection/tests/workspace-files.spec.ts | 142 --------------- .../client/runtime/tests/client-apply.spec.ts | 1 - .../client/runtime/tests/wire-events.spec.ts | 1 - packages/client/test-runtime/package.json | 2 - .../client/test-runtime/src/connection.ts | 48 ----- packages/client/test-runtime/src/index.ts | 6 - packages/client/test-runtime/tsconfig.json | 3 - .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- packages/client/ui-conversation/package.json | 2 - .../ui-conversation/src/client/apply.ts | 13 +- .../src/client/chat/ToolRow.module.css | 13 +- .../tests/apply-inject.spec.tsx | 16 +- .../tests/chat-code-subcalls.spec.tsx | 7 +- .../tests/chat-toolview-slot.spec.tsx | 7 +- packages/client/ui-conversation/tsconfig.json | 3 - packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 - packages/host/apiproxy/README.zh.md | 2 - packages/host/apiproxy/src/api-proxy.ts | 15 -- packages/host/apiproxy/src/api/files.ts | 98 ----------- packages/host/apiproxy/src/api/index.ts | 17 -- packages/host/apiproxy/src/index.ts | 2 - .../host/apiproxy/src/native-path-opener.ts | 76 +++++++- .../tests/api-proxy-workspace.spec.ts | 32 +--- .../apiproxy/tests/client-handler.spec.ts | 2 - .../host/apiproxy/tests/fetch-carrier.spec.ts | 2 - .../host/apiproxy/tests/files-path.spec.ts | 74 -------- .../apiproxy/tests/native-path-opener.spec.ts | 93 ++++++++++ pnpm-lock.yaml | 6 - tsconfig.host.json | 2 +- 50 files changed, 317 insertions(+), 1211 deletions(-) create mode 100644 apps/web/tests/produced-files.e2e.ts delete mode 100644 apps/web/tests/workspace-file-open.e2e.ts delete mode 100644 packages/client/connection/src/files-server.ts delete mode 100644 packages/client/connection/src/workspace-files.ts delete mode 100644 packages/client/connection/tests/files-server.spec.ts delete mode 100644 packages/client/connection/tests/workspace-files.spec.ts delete mode 100644 packages/client/test-runtime/src/connection.ts delete mode 100644 packages/host/apiproxy/src/api/files.ts delete mode 100644 packages/host/apiproxy/tests/files-path.spec.ts 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 e8510d24e5..2b75bc2eff 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: 8eeb96517aa905e77e50ce36f0efb704353c0821 -2026-07-31-web-workspace-file-links.zh.md: 63d746b0a9aabaf78ba5653e16705bd662a54126 +2026-07-31-web-workspace-file-links.md: da99426ecb5ca81dcc110bbd4d5c1218390ae4bd +2026-07-31-web-workspace-file-links.zh.md: 91aa94c6fe253c64125eb31fd15973a5aaff1a8f 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 8eeb96517a..da99426ecb 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 @@ -4,36 +4,32 @@ Status: implemented English | [中文](2026-07-31-web-workspace-file-links.zh.md) -> Scope: the `/f` workspace-file route on the web transport, the `IWorkspaces.fileUrl` derivation behind it, the conversation's file-open affordance switching to it, and the produced-files row a finished turn ends with. Not in scope: an artifact registry, versioning, live reload, or any model-facing declaration. +> Scope: the produced-files row a finished turn ends with, the file-path link that reads as one, and the Host opener preferring the default browser for documents a browser renders. Not in scope, by decision: serving workspace files over HTTP, and previews for a client that is not on the Host machine. ## Problem 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. -The parts were nearly all present, pointed at the wrong target. `ToolRow` already renders a mutation or read row's path as a real button, `ui-conversation` already routes its click through `openFile`, and `workspaces.openPath` already carries it to the Host's system opener. But that opener runs on the Host machine, and `host.openPath` is loopback-pinned by the `/api` trust fence, so the affordance answered nothing for a browser reached over the LAN and was invisible even locally (the path styled as plain text, underlined only on hover). Meanwhile `MarkdownText` strips every non-`http(s)` URL, so a path the model wrote into its closing message could never become a link at all, and `ToolCallView.locations` — the follow-along vocabulary the file tools already populate — had no consumer in the client. +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. ## Decision -**One prefix route on the transport that already exists, not a new capability.** `client-connection` owns both browser-facing prefixes: `/api` for RPC and `/f/<sessionId>/<segments…>` for workspace-file reads. It was already the package holding `httpServer`, the `trustedHosts` config, and the browser-trust fence; a separate package would have duplicated the fence and the config, and forced `AppCLIEntry` to patch two rows for one `--trusted-host` flag. The webserver's own contract — every feature surface is a route some other plugin registers — makes the route the whole mechanism. Segments ride the path rather than a query parameter so a served document's relative references resolve to its siblings. +**A finished turn ends with the files it produced.** `turnDeliverables` reads them off the mutation tools' own follow-along `locations` — a diff card, or a generic card whose `kind` is `edit` (the shape `str_replace_editor`'s insert presents) — so a turn's output is listed whether or not the closing message named it, and a new mutation tool joins by declaring what it does rather than by being added to a list. Reads, deletes, and failed calls contribute nothing; a path appears once per turn in first-seen order; accumulation resets on the turn boundary, so a turn that mutates and then ends without content text cannot spill into the next turn's row. The row renders under the closing assistant's body and above its IconActions, keyed to the seq `assistantActionsSeqs` already elects. -**The request names a Session; the gateway names the authority.** `ApiProxy.workspaceRootOf` answers where a Session's files live — a live agent's `session.header.cwd` first, then the persistence store, never a resume — as a second, non-envelope face of the `cwd` the session summaries already carry. The route reads that instead of `ctx.agents` directly, because `client-connection` is registered in the client program and importing the core service packages merges their host-side `sessions: SessionStore` declaration over the browser runtime's own `sessions: SessionsService` — the collision `tsconfig.host.json`/`tsconfig.client.json` exist to prevent. Both the cwd and the resolved target go through `realpath` before the prefix comparison, so a workspace-internal symlink pointing outward is refused by its target; traversal spellings are refused at parse time, before any filesystem call. Reads stream through `pipeline`, so a client that goes away destroys the descriptor and no request ever buffers a file. +**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. -**The URL shape lives in `dsh-host-apiproxy/api`, with the other browser-importable contract surfaces.** Both ends must agree on one encoding, but a client bundle may not value-import another plugin's package: the purity gate in `packages/client/tsdown.client.ts` allows only platform modules and the `INLINE_SAFE` wire layers, of which apiproxy is one. Putting `api/files.ts` there is what lets the browser half build a URL and the serving half parse it from a single source, and it needed no new package edge — both sides already depend on apiproxy. +**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, because a developer who binds `.html` to an editor would otherwise click a produced page and get source code. Each platform answers "which browser" as completely as it can (macOS from the LaunchServices `https` handler, Linux from `$BROWSER`), and every failure falls back to the default application rather than surfacing. -**Workspace files get their own port, and therefore their own origin.** The isolation question was worked three ways before landing here. A sandbox header came first, on the reasoning that `/api/events.mux` is a readable same-origin `GET` stream. It was then dropped on the premise that these files are agent-authored, so a browser boundary would sit behind one already crossed — a premise review falsified: a read row makes every file in a cloned repository openable, and a same-origin active document was measured driving `/api/settings.describe` to a `200` with full data, reaching the loopback-pinned settings and credential plane from a page nobody in this session wrote. Restoring the sandbox closed that, and measuring what it cost decided the final shape: under `CSP: sandbox` the report's own artifact throws `SecurityError` on load, and because an uncaught exception aborts the rest of its `<script>`, every listener declared after that line — theme toggle, mobile menu, model tabs — never binds. Two of the four artifacts in the reporting user's workspace were dead pages under it, and they still *looked* right. A second port is the boundary without the amputation: cross-origin to `/api` (refused by the fence's Origin check and by CORS), same-origin with itself (so `localStorage`, cookies, and `fetch` all work). It binds the same host as the API so LAN previews keep working, answers `/f` and nothing else, and publishes its port into the index page for the browser half to address. - -**The client decides by derivation, not by probing.** `ConnectionHandle.fileUrl(sessionId, cwd, path)` expresses a tool-reported path as segments below the session cwd and returns an absolute URL on the workspace-file origin — the page's own hostname, the published port — or `undefined` when the path leaves the workspace or no port was published. It lives on the connection handle because the transport owns both ends: the listener that serves the bytes and the port that addresses it. `undefined` is exactly the signal to fall back to `openPath`, which is also what makes the keyless fixture lane (served by no host) degrade to the old behavior instead of opening a dead tab. +**Serving workspace files over HTTP is out of scope, and so are non-local clients.** An earlier revision served files from the harness itself — first same-origin with `/api`, then behind `CSP: sandbox`, then from a second listener whose own port gave served documents their own origin. Each step answered a real problem, and the whole line was retired once the product scope was settled: previews for a browser that is not on the Host machine are not supported. With that decided, the Host opener answers the supported case completely and the HTTP machinery answered only the unsupported one. ## Alternatives considered -- **The artifact capability family (RFC #268 / PR #272)** — a seam with ids, versions, snapshot storage, its own HTTP server, SSE live reload, and a browser auto-opener. Its review found seven critical issues, and every one of them came from that machinery: an unlistened opener spawn crashing the harness, the opener inheriting `DEEPSEEK_API_KEY`, in-flight publishes outliving disposal, `readFile` preceding the size cap, a snapshot TOCTOU, and retention leaking with undisposed agents. `dsh web` already runs an HTTP server and the user is already in a browser, so none of that machinery buys anything here. The RFC and its tests stay as the input for the day a real cross-session or versioned-artifact need appears; this route is that seam's natural mount point when it does. -- **A dedicated `dsh-client-workspace-files` package** — the honest seam shape if file serving were an independent capability. It is not: it needs the same fence and the same `trustedHosts` value as `/api`, and splitting would have duplicated both against the repository's own "don't split preemptively" rule. -- **Keeping the URL-shape module in `client-connection` and importing it from the runtime** — the first cut, and the build refused it: a cross-plugin value import into a client bundle either inlines a duplicate runtime instance or names a specifier the frozen module table cannot answer. The gate is the reason the shared module sits in the wire layer rather than in the package that happens to own the route. -- **`/f/<absolute path>`, so `openPath` could stay the single call site** — drops the sessionId from the URL, but then the served authority becomes the union of every workspace the host knows. The tight authority costs exactly one call-site edit, because `openFile` already has both the sessionId and the cwd in scope. -- **`connect-src 'none'` plus a navigation fence, to keep `localStorage` working under a sandbox** — measurably viable against the SSE-read vector (Chrome sends `Sec-Fetch-Dest: document` for `window.open` and `empty` for `EventSource`, loopback included), but it never addressed the larger one: same-origin `fetch` to a POST method is what reaches the configuration plane, and blocking `connect-src` from the served document is exactly what a hostile document would not do to itself. -- **Keeping the sandbox and accepting the limitation** — the honest reading of that trade only became visible once measured: it is not "a preview cannot remember a theme" but "a preview's entire script dies at its first storage access", on pages that still render perfectly. A limitation nobody can see is worse than one that costs a port. -- **Linkifying paths in the assistant's closing message** — the shape a user asks for ("put the link at the end"), but it makes rendering depend on the model spelling a path recognizably. The tool calls already carry `locations` as structured fact, so the produced-files row consumes that instead; linkifying the prose stays unnecessary rather than merely deferred. +- **Serving `/f/<sessionId>/<segments…>` from the harness** — built and working, including confinement by dual `realpath`, the browser-trust fence, streamed reads, and a separate listener whose port gave served documents their own origin. It is the only design that shows a preview to a client on another machine, which is exactly the case ruled out of scope. Retired for that reason, not because it failed; its cost was a second socket with its own lifecycle, a port published into the page, and a URL-shape contract shared across two packages. +- **Same-origin HTTP serving without isolation** — measurably unsafe, and recorded so nobody retries it: a document served beside `/api` drove `settings.describe` to a `200` with full data and `session.list` to 35 KB of every session's transcript, from a page that need not be agent-authored at all (a read row makes every file in a cloned repository openable). +- **`Content-Security-Policy: sandbox` over that same-origin serving** — closes the hole by taking the document's origin away, which measurably breaks the pages this feature exists to show: the reported artifact throws `SecurityError` on load, and because an uncaught exception aborts the rest of its `<script>`, every listener declared after that line — theme toggle, mobile menu, model tabs — never binds. Two of the four artifacts in the reporting user's workspace were dead pages under it, and they still rendered perfectly, so the breakage was invisible. +- **Linkifying paths in the assistant's closing message** — the shape a user asks for ("put the link at the end"), but it makes rendering depend on the model spelling a path recognizably. The tool calls already carry `locations` as structured fact, so the produced-files row consumes that instead. +- **An embedded WebView in the desktop shell** — the strongest isolation available, since the preview then runs in a container the product owns rather than in the user's browser. It belongs to the desktop shell's own design, not to this surface, and is recorded here as the direction a future preview capability should take. ## Consequences -Every existing file affordance changed target at once: write, edit, read, and the generic single-file card all reach `openFile`, so one call-site edit made produced files openable in the browser, LAN clients included. Three tests asserting the old `openPath` destination were rewritten to the new one; the outside-workspace fallback keeps the old assertion. The route is covered against a real HTTP server and a real temporary workspace, because confinement, content typing, and the sandbox header are wire facts, and the assembled web lane (`apps/web/tests/workspace-file-open.e2e.ts`, keyless over a cold-seeded session) proves the product path: clicking a read row's path opens `/f/<sessionId>/a.txt` in a second tab serving that workspace file, while a traversal spelling answers 404. A preview runs with its own origin's full capabilities, so a generated page behaves as its author intended. The residual the port does not close: two Sessions share one workspace-file origin, so a document from one may fetch another's served files. That is strictly narrower than the API surface it replaces, and narrowing it further would mean an origin per Session, which nothing today needs. The produced-files row ships here too: `turnDeliverables` reads a turn's output off the mutation tools' render intent (a diff card, or a generic card whose `kind` is `edit`), resets on the turn boundary so an interrupted turn cannot spill into the next, and renders under the closing assistant. Still deferred: linkification inside assistant Markdown, and any cross-session view of past deliverables. +Every existing file affordance changed at once: write, edit, read, and the generic single-file card all reach `openFile`, so the link fix and the browser preference apply to all of them without a per-row change. The keyless web lane (`apps/web/tests/produced-files.e2e.ts`) cold-seeds a recorded write turn and pins the row in the assembled application; it deliberately does not click, because the click hands a path to the Host's opener and would launch a real application on the machine running the suite. A produced file opens as a `file://` document, which cannot `fetch` its own siblings (a multi-file artifact that loads `./data.json` breaks, while `<script src>`, `<img>`, and CSS `@import` are unaffected) — the one capability HTTP serving had that this does not. A client reached over the network sees nothing when it clicks: `host.openPath` runs on the Host and is loopback-pinned by the `/api` trust fence. That is the scope decision showing through, not a defect, and it is why the row keeps the full path in its `title` for a reader who can only copy it. Markdown opens in whatever the platform hands `.md`, usually an editor rather than a renderer; rendering it inside the product is a separate, deferred surface. 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 63d746b0a9..91aa94c6fe 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 @@ -4,36 +4,32 @@ Status: implemented [English](2026-07-31-web-workspace-file-links.md) | 中文 -> 范围:web 传输层上的 `/f` 工作区文件路由、其背后的 `IWorkspaces.fileUrl` 推导、会话中打开文件的交互改指向它,以及完成的一轮以其产出文件收尾的那一行。不在范围内:产物注册表、版本、实时重载,或任何面向模型的声明。 +> 范围:完成的一轮以其产出文件收尾的那一行、读得出是链接的文件路径链接,以及 Host 打开器对浏览器可渲染文档优先选用默认浏览器。经决定不在范围内:以 HTTP 提供工作区文件,以及为不在 Host 机器上的客户端提供预览。 ## 问题 一个产出了文件的 web 会话,没有办法看到那个文件。agent 写出了 `deepseek-homepage.html` 并如实告知,而用户唯一的办法是把 `/private/tmp/dsh-client-hotplug.ygPvsm/workspaces/plugin-hotplug/deepseek-homepage.html` 这样的绝对路径复制进终端。 -零件几乎都在,只是指错了目标。`ToolRow` 早已把改写行或读取行的路径渲染成一个真正的按钮,`ui-conversation` 早已把它的点击经由 `openFile` 转发,`workspaces.openPath` 也早已把它送到 Host 的系统打开器。但那个打开器运行在 Host 机器上,而 `host.openPath` 被 `/api` 信任 fence 钉在回环,所以这个交互对经 LAN 访问的浏览器什么都答不了,即便在本机也是隐形的(路径的样式就是普通文本,只有 hover 时才有下划线)。与此同时 `MarkdownText` 会剥掉每一个非 `http(s)` 的 URL,因此模型写进收尾消息里的路径根本不可能成为链接;而 `ToolCallView.locations`——文件工具早已填好的跟随文件词汇——在客户端没有任何消费方。 +这背后是两个不同的缺陷。转录从不说明一轮产出了什么:`ToolCallView.locations`——文件工具早已填好的跟随文件词汇——在客户端没有任何消费方,因此读者对产出的唯一交代,就是收尾消息恰好拼出来的那点内容。而已经存在的那个交互是隐形的:`ToolRow` 早已把改写行或读取行的路径渲染成一个接到 `host.openPath` 的真按钮,但它的样式与周围正文一模一样、只有悬停才有下划线,于是没人发现。所报告的“做完了打不开”,是一个可发现性失败叠在一项本就可用的能力之上。 ## 决定 -**在已有的传输层上加一条前缀路由,而不是加一项能力。** `client-connection` 持有两条面向浏览器的前缀:`/api` 承载 RPC,`/f/<sessionId>/<segments…>` 承载工作区文件读取。它本来就是持有 `httpServer`、`trustedHosts` 配置和浏览器信任 fence 的那个包;单开一个包会把 fence 和配置各复制一份,并逼着 `AppCLIEntry` 为一个 `--trusted-host` 标志去 patch 两行。webserver 自己的契约——每个特性面都是别的插件注册的一条路由——让这条路由本身就是全部机制。段落走路径而非查询参数,是为了让所服务文档的相对引用能解析到它的同级文件。 +**完成的一轮以它产出的文件收尾。** `turnDeliverables` 从改写工具自身的跟随文件 `locations` 中读出它们——diff 卡片,或 `kind` 为 `edit` 的 generic 卡片(即 `str_replace_editor` 的 insert 所呈现的形状)——因此无论收尾消息是否点名,这一轮的产出都会被列出;新的改写工具靠声明自己做了什么加入,而不是靠被加进某张名单。read、删除与失败的调用不贡献任何条目;同一路径在一轮内按首见顺序只出现一次;累积在 turn 边界重置,因此一轮若先改写文件、随后没有正文内容就结束,不会溢进下一轮的行里。该行渲染在收尾 assistant 正文之下、其 IconActions 之上,键控到 `assistantActionsSeqs` 早已选出的那个 seq。 -**请求指名 Session,由网关指名权限边界。** `ApiProxy.workspaceRootOf` 回答某个 Session 的文件位于何处——先看活跃 agent 的 `session.header.cwd`,再看持久化存储,绝不恢复会话——它是会话摘要早已携带的那个 `cwd` 的第二副面孔,只是不带信封。路由读取它而不是直接够 `ctx.agents`,因为 `client-connection` 注册在 client 程序里,而引入核心服务包会把它们 host 侧的 `sessions: SessionStore` 声明盖到浏览器运行时自己的 `sessions: SessionsService` 之上——这正是 `tsconfig.host.json`/`tsconfig.client.json` 分立所要防的那种冲突。cwd 与解析出的目标在前缀比较前都要过 `realpath`,因此工作区内指向工作区外的符号链接会因其目标而被拒绝;穿越写法在解析期就被拒,早于任何文件系统调用。读取经 `pipeline` 流出,因此客户端离开即销毁描述符,任何请求都不会把文件缓冲起来。 +**路径链接读得出是链接。** 静止状态下就带下划线,而不只在悬停时。这是本次改动中更小的那一半,却是修复中更大的那一半。 -**URL 形状落在 `dsh-host-apiproxy/api`,与其余浏览器可导入的契约面同处一地。** 两端必须就同一套编码达成一致,但客户端 bundle 不允许值导入另一个插件的包:`packages/client/tsdown.client.ts` 里的纯度 gate 只放行平台模块与 `INLINE_SAFE` 协议层,而 apiproxy 正是其中之一。把 `api/files.ts` 放在那里,才使构造 URL 的浏览器半侧与解析它的服务半侧共用单一来源,而且没有新增任何包依赖边——两侧本来就依赖 apiproxy。 +**打开仍然是 Host 的职责,并且优先选用默认浏览器。** `host.openPath` 把路径交给操作系统,得到的是真实浏览器里的一份 `file://` 文档:页面能力完整,且够不到 `/api`——因为 `file://` 文档与它并不同源。在所报告的那份产物上实测:`localStorage` 可用、主题切换生效、tabs 可切换,而对 API 的 `fetch` 失败。对浏览器能渲染的文档——`.html`、`.htm`、`.xhtml`、`.svg`——打开器解析的是默认**浏览器**而非该类型的默认应用,因为把 `.html` 绑给编辑器的开发者,否则点开一个产出的页面得到的会是源码。每个平台在自己能力范围内回答“哪个浏览器”(macOS 取 LaunchServices 的 `https` 处理程序,Linux 取 `$BROWSER`),任何一步失败都回退到默认应用,而不是把失败抛给用户。 -**工作区文件获得自己的端口,因而拥有自己的源。** 隔离这件事在落到此处之前走了三步。最初是加 sandbox 头,理由是 `/api/events.mux` 是一条同源可读的 `GET` 流。随后它被拿掉,前提是这些文件由 agent 撰写、浏览器边界只会立在一条早已越过的边界之后——而评审推翻了这个前提:一条 read 行就让 clone 下来的仓库里任何文件变得可打开,而同源的活动文档经实测能把 `/api/settings.describe` 打到 `200` 并拿到完整数据,从一个本次会话中无人撰写的页面触达了被钉在回环的设置与凭据面。加回 sandbox 堵住了它,而“量清楚它的代价”决定了最终形状:在 `CSP: sandbox` 之下,报告中那份产物加载时就抛 `SecurityError`,又因为未捕获异常会中止其 `<script>` 的其余部分,该行之后声明的所有监听器——主题切换、移动端菜单、模型 tabs——统统不会绑定。报告者工作区里四份产物有两份在它之下是死页面,而且它们**看上去**仍然正常。第二个端口给出了这条边界而无需截肢:对 `/api` 是跨源(被 fence 的 Origin 校验与 CORS 双重拒绝),对自身是同源(因此 `localStorage`、cookie 与 `fetch` 都可用)。它绑定与 API 相同的 host,因此 LAN 预览继续可用;只应答 `/f`,别无其他;并把端口注入首页供浏览器半侧寻址。 - -**客户端靠推导决定,而不是靠探测。** `ConnectionHandle.fileUrl(sessionId, cwd, path)` 把工具报告的路径表达为 session cwd 之下的段落,并返回工作区文件源上的绝对 URL——页面自身的主机名,加上已发布的端口——路径离开工作区或没有端口发布时返回 `undefined`。它落在 connection 句柄上,是因为传输层同时持有两端:提供字节的监听器,和寻址它的端口。`undefined` 恰好就是回退到 `openPath` 的信号,这也让无密钥 fixture 通道(不由任何 host 提供)退化为旧行为,而不是打开一个空标签页。 +**以 HTTP 提供工作区文件不在范围内,非本机客户端亦然。** 更早的一版由 harness 自己提供文件——先是与 `/api` 同源,随后加上 `CSP: sandbox`,再后来交给一个以自身端口给所服务文档独立源的第二监听器。每一步都在回答一个真实问题,而整条线在产品范围定下之后被整体退役:不为“浏览器不在 Host 机器上”的场景提供预览。这一点定下之后,Host 打开器完整回答了受支持的场景,而那套 HTTP 机制回答的只是不受支持的那个。 ## 考虑过的替代方案 -- **产物能力族(RFC #268 / PR #272)**——一条带 id、版本、快照存储、自有 HTTP 服务器、SSE 实时重载与浏览器自动打开器的 seam。它的评审给出了七个 critical,而每一个都来自那套机械结构:未监听的打开器 spawn 会让 harness 崩溃、打开器继承 `DEEPSEEK_API_KEY`、进行中的 publish 活过 dispose、`readFile` 先于大小上限、快照的 TOCTOU,以及未 dispose 的 agent 导致保留期泄漏。`dsh web` 本来就跑着一个 HTTP 服务器,用户本来就在浏览器里,那套机械结构在这里买不到任何东西。RFC 与其测试保留下来,作为真正出现跨会话或版本化产物需求那天的输入;届时这条路由就是那条 seam 的天然挂载点。 -- **单开一个 `dsh-client-workspace-files` 包**——如果文件服务是一项独立能力,这才是诚实的 seam 形状。它不是:它需要与 `/api` 相同的 fence 和相同的 `trustedHosts` 值,拆分会把两者都复制一份,违背仓库自己的“不要预先拆分”。 -- **把 URL 形状模块留在 `client-connection` 里、由 runtime 去导入**——最初就是这么写的,构建直接拒绝:向客户端 bundle 做跨插件值导入,要么内联出一份重复的运行时实例,要么落到冻结模块表答不出的说明符上。这道 gate 正是共享模块落在协议层、而非落在恰好持有该路由的那个包里的原因。 -- **`/f/<绝对路径>`,好让 `openPath` 保持为唯一调用点**——这会把 sessionId 从 URL 里去掉,但所服务的权限边界随之变成 host 已知的全部工作区之并集。紧的权限边界只花掉一处调用点的改动,因为 `openFile` 本来就同时持有 sessionId 与 cwd。 -- **用 `connect-src 'none'` 加一道导航栅栏,在 sandbox 之下保住 `localStorage`**——针对“读走 SSE 流”这条向量经实测可行(Chrome 对 `window.open` 发 `Sec-Fetch-Dest: document`、对 `EventSource` 发 `empty`,回环也在内),但它从未触及更大的那条:真正够到配置面的是向 POST 方法发起的同源 `fetch`,而“从所服务文档一侧封住 `connect-src`”恰恰是敌意文档不会对自己做的事。 -- **保留 sandbox 并接受这条限制**——这笔交易的真实读数要量过才看得见:它不是“预览记不住主题”,而是“预览的整段脚本在第一次访问存储时就死了”,而页面照样渲染得完美无缺。一条没人看得见的限制,比一条要花掉一个端口的限制更糟。 -- **把路径在助手的收尾消息里链接化**——这是用户开口要的形状(“在结尾附上链接”),但它让渲染取决于模型是否把路径拼写得可识别。工具调用已经把 `locations` 作为结构化事实携带,产出文件行消费的正是它;因此把正文链接化是不必要,而不只是被推迟。 +- **由 harness 提供 `/f/<sessionId>/<segments…>`**——已经实现并可用,包含双 `realpath` 收敛、浏览器信任 fence、流式读取,以及一个以自身端口给所服务文档独立源的监听器。它是唯一能把预览呈现给另一台机器上客户端的设计,而那恰恰是被判出范围的场景。因此退役,而不是因为它失败了;它的代价是一个带自身生命周期的第二 socket、一个注入页面的端口,以及一份跨两个包共享的 URL 形状契约。 +- **同源 HTTP 提供且不加隔离**——经实测不安全,记录在此以免有人重试:与 `/api` 并排提供的文档把 `settings.describe` 打到 `200` 并拿到完整数据,把 `session.list` 打到 35 KB 的全部会话转录,而这个页面根本不必由 agent 撰写(一条 read 行就让 clone 下来的仓库里任何文件变得可打开)。 +- **在那套同源提供之上加 `Content-Security-Policy: sandbox`**——它以剥夺文档的源来堵住这个洞,而这经实测会破坏本功能存在的意义所在的那类页面:所报告的产物在加载时抛 `SecurityError`,又因为未捕获异常会中止其 `<script>` 的其余部分,该行之后声明的所有监听器——主题切换、移动端菜单、模型 tabs——统统不会绑定。报告者工作区里四份产物有两份在它之下是死页面,而且它们渲染得完美无缺,所以这种破坏是看不见的。 +- **把路径在助手的收尾消息里链接化**——这是用户开口要的形状(“在结尾附上链接”),但它让渲染取决于模型是否把路径拼写得可识别。工具调用已经把 `locations` 作为结构化事实携带,产出文件行消费的正是它。 +- **桌面端外壳中的内嵌 WebView**——可得到的最强隔离,因为那时预览跑在产品自己拥有的容器里,而不是用户的浏览器里。它属于桌面端外壳自身的设计,而非本交互面,记录在此作为未来预览能力应走的方向。 ## 影响 -现有的每一处文件交互都同时换了目标:write、edit、read 与通用单文件卡片都汇到 `openFile`,因此一处调用点的改动就让产出的文件在浏览器里可打开,LAN 客户端也在内。三个断言旧 `openPath` 去向的测试被改写为新的去向;工作区外的回退保留了旧断言。这条路由对着真实 HTTP 服务器与真实临时工作区做覆盖,因为收敛、内容定型与 sandbox 头都是协议事实;而组装后的 web 通道(`apps/web/tests/workspace-file-open.e2e.ts`,在冷播种会话上无密钥运行)证明了产品路径:点击读取行的路径会在第二个标签页打开 `/f/<sessionId>/a.txt` 并提供那个工作区文件,而穿越写法应答 404。预览以自身源的完整能力运行,因此生成的页面按其作者的意图工作。端口没有堵住的残余:两个 Session 共用同一个工作区文件源,因此来自其一的文档可以 fetch 另一个已服务的文件。这比它所替代的 API 面严格更窄,而要再窄一层就意味着每个 Session 一个源,今天没有任何需求指向那里。产出文件行也在本次一并落地:`turnDeliverables` 依据改写工具的渲染意图(diff 卡片,或 `kind` 为 `edit` 的 generic 卡片)读出一轮的产出,在 turn 边界重置以免中断的一轮溢进下一轮,并渲染在收尾 assistant 之下。仍然暂缓:助手 Markdown 内部的链接化,以及任何跨会话回看既往产物的视图。 +现有的每一处文件交互都同时改变了:write、edit、read 与通用单文件卡片都汇到 `openFile`,因此链接可见性修复与浏览器优先策略无需逐行改动即适用于全部。无密钥 web 通道(`apps/web/tests/produced-files.e2e.ts`)冷播种一段录制的 write 轮次,在组装后的应用中钉住该行;它刻意不点击,因为点击会把路径交给 Host 打开器,从而在跑测试的机器上启动一个真实应用。产出的文件以 `file://` 文档打开,它无法 `fetch` 自己的同级文件(一个加载 `./data.json` 的多文件产物会坏,而 `<script src>`、`<img>` 与 CSS `@import` 不受影响)——这是 HTTP 提供曾有、而此处没有的那一项能力。经网络访问的客户端点击后看不到任何东西:`host.openPath` 在 Host 上运行,且被 `/api` 信任 fence 钉在回环。那是范围决定的显现,不是缺陷,也正因如此该行把完整路径保留在 `title` 中,供只能复制它的读者使用。markdown 会由平台交给 `.md` 的默认处理程序打开,通常是编辑器而非渲染器;在产品内渲染它是另一个被推迟的交互面。 diff --git a/apps/web/tests/produced-files.e2e.ts b/apps/web/tests/produced-files.e2e.ts new file mode 100644 index 0000000000..c96131bf69 --- /dev/null +++ b/apps/web/tests/produced-files.e2e.ts @@ -0,0 +1,76 @@ +// Web e2e scenario: the produced-files row a finished turn ends with. Cold-seeds +// a recorded write turn (zero model calls). Package tests cover the derivation +// in isolation, but only the assembled application shows that a turn's writes +// reach the transcript as an openable row (docs/testing.md snapshot rule). The +// click itself is not driven here: it hands the path to the Host's opener, +// which would launch a real application on the machine running the suite. +import { readFile, writeFile, mkdir } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +// Borrowed read-only: this scenario needs any settled turn whose tools WROTE a +// file, not a new recording (the message-actions borrowing pattern). +const SEED = fileURLToPath(new URL('./snapshots/permission-policy-context/session.jsonl', import.meta.url)) +const MODE = webSnapshotMode() +const SEED_ID = 'produced-files-web-e2e' + +/** The file the borrowed recording's write tool produces. */ +const PRODUCED = 'policy-neutral.txt' + +describe('web e2e: a finished turn ends with the files it produced', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType<typeof watchConsole> + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + // The seeded Session's cwd is the scaffold workspace; the recording's own + // nested directory is created too, so its paths stay resolvable. + await mkdir(join(scaffold.workspaceCwd, 'workspace'), { recursive: true }) + await writeFile(join(scaffold.workspaceCwd, PRODUCED), 'neutral\n') + const raw = await readFile(SEED, 'utf8') + expect(raw, 'borrowed recording must carry the write this scenario reads').toContain(PRODUCED) + await seedSession(scaffold, raw, SEED_ID) + 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 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it.skipIf(MODE === 'record')('lists the written file under the closing message, as an opener', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-produced-files')) + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + + // The row the turn ends with — derived from the write call's locations, + // not from whatever the closing message happened to say. + const chip = page.getByRole('button', { name: `Open ${PRODUCED}`, exact: true }).first() + await chip.waitFor({ timeout: 15_000 }) + expect(await chip.innerText()).toBe(PRODUCED) + // The full path stays reachable for a reader who wants to copy it. + expect(await chip.getAttribute('title')).toContain(PRODUCED) + // A turn's produced files are labelled, not left as bare chips. + expect(await page.getByText('Produced', { exact: true }).count()).toBeGreaterThan(0) + + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 90_000) +}) diff --git a/apps/web/tests/workspace-file-open.e2e.ts b/apps/web/tests/workspace-file-open.e2e.ts deleted file mode 100644 index 8f6bd6f665..0000000000 --- a/apps/web/tests/workspace-file-open.e2e.ts +++ /dev/null @@ -1,122 +0,0 @@ -// Web e2e scenario: a produced file, from the row that lists it to the bytes -// the browser gets. Cold-seeds a recorded write turn (zero model calls). -// Package tests cover the derivation and the route in isolation, but only the -// assembled application shows that the turn's Produced row, the URL it opens, -// and the file on disk are the same thing (docs/testing.md snapshot rule). -import { readFile, writeFile, mkdir } from 'node:fs/promises' -import { join } from 'node:path' -import { fileURLToPath } from 'node:url' -import type { Browser, Page } from 'playwright' -import { chromium } from 'playwright' -import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' -import { - launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, -} from './scaffold.ts' -import { newEnglishPage, saveFailureShot } from './support.ts' - -// Borrowed read-only: this scenario needs any settled turn whose tools WROTE a -// file, not a new recording (the message-actions borrowing pattern). -const SEED = fileURLToPath(new URL('./snapshots/permission-policy-context/session.jsonl', import.meta.url)) -const MODE = webSnapshotMode() -const SEED_ID = 'workspace-file-open-web-e2e' - -/** The file the borrowed recording's write tool produces. */ -const PRODUCED = 'policy-neutral.txt' -/** An active document placed alongside it, for the isolation header the route puts on those. */ -const ACTIVE = 'preview.html' - -describe('web e2e: opening a produced file from the conversation', () => { - let scaffold: WebScaffold - let browser: Browser - let page: Page - let tripwire: ReturnType<typeof watchConsole> - - beforeAll(async () => { - scaffold = await launchWebScaffold({}) - // The seeded Session's cwd is the scaffold workspace; the recording's own - // nested directory is created too, so its paths stay resolvable. - await mkdir(join(scaffold.workspaceCwd, 'workspace'), { recursive: true }) - await writeFile(join(scaffold.workspaceCwd, PRODUCED), 'neutral\n') - await writeFile(join(scaffold.workspaceCwd, ACTIVE), '<h1>produced</h1>\n') - const raw = await readFile(SEED, 'utf8') - expect(raw, 'borrowed recording must carry the write this scenario reads').toContain(PRODUCED) - await seedSession(scaffold, raw, SEED_ID) - 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 }) - }, 120_000) - - afterAll(async () => { - await browser?.close() - await scaffold?.close() - }) - - it.skipIf(MODE === 'record')('ends the turn with its produced file, which opens as the workspace file itself', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-workspace-file-open')) - const groupRow = page.locator('[role="treeitem"]').first() - await groupRow.waitFor({ timeout: 15_000 }) - await groupRow.click() - const sessionRow = page.locator('[role="treeitem"]').nth(1) - await sessionRow.waitFor({ timeout: 10_000 }) - await sessionRow.click() - - // The row the turn ends with — derived from the write call's locations, - // not from whatever the closing message happened to say. - const chip = page.getByRole('button', { name: `Open ${PRODUCED}`, exact: true }).first() - await chip.waitFor({ timeout: 15_000 }) - expect(await chip.innerText()).toBe(PRODUCED) - - const [opened] = await Promise.all([ - page.context().waitForEvent('page', { timeout: 15_000 }), - chip.click(), - ]) - await opened.waitForLoadState('domcontentloaded') - const url = new URL(opened.url()) - expect(url.pathname).toBe(`/f/${SEED_ID}/${PRODUCED}`) - expect(await opened.locator('body').innerText()).toContain('neutral') - - // The isolation: previews come from the app's hostname on a DIFFERENT - // port, so a served document is cross-origin to /api while keeping its own - // capabilities. A workspace file is not necessarily agent-authored. - const app = new URL(scaffold.baseUrl) - expect(url.hostname).toBe(app.hostname) - expect(url.port).not.toBe(app.port) - const filesOrigin = url.origin - - const served = await page.request.get(opened.url()) - expect(served.status()).toBe(200) - expect(served.headers()['x-content-type-options']).toBe('nosniff') - expect(served.headers()['cache-control']).toBe('no-store') - // No document is stripped of its origin: the port is the boundary. - expect(served.headers()['content-security-policy']).toBeUndefined() - - // An active document keeps its own storage — the capability a sandbox - // header would have taken, and the reason this route has its own port. - const active = opened - await active.goto(`${filesOrigin}/f/${SEED_ID}/${ACTIVE}`, { waitUntil: 'load' }) - expect(await active.evaluate(() => { - try { window.localStorage.setItem('probe', '1'); return 'ok' } catch { return 'blocked' } - })).toBe('ok') - // …and cannot reach the API, which lives on the other origin. - expect(await active.evaluate(async (base) => { - try { - await fetch(`${base}/api/session.list`, { - method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ type: 'client-request', rpcId: 'x', method: 'session.list', payload: {} }), - }) - return 'reached' - } catch { return 'blocked' } - }, scaffold.baseUrl)).toBe('blocked') - - // The workspace-file origin serves that one prefix and nothing else. - expect((await page.request.get(`${filesOrigin}/`)).status()).toBe(404) - // Nothing outside the Session's workspace is reachable through the route. - expect((await page.request.get(`${filesOrigin}/f/${SEED_ID}/..%2Fetc%2Fhosts`)).status()).toBe(404) - - await active.close() - expect(tripwire.pageErrors).toEqual([]) - expect(tripwire.warnings).toEqual([]) - }, 90_000) -}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 2c65f1e510..48db884aad 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -51,7 +51,7 @@ "tests/access-confirmation.e2e.ts", "tests/shipped-composition.e2e.ts", "tests/startup-auto-selection.e2e.ts", - "tests/workspace-file-open.e2e.ts" + "tests/produced-files.e2e.ts" ], "references": [ { diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 7c21713f01..a356a151e3 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -296,7 +296,7 @@ export interface ConnectionConfig { } ``` -Source: [`packages/client/connection/src/index.ts:25`](../packages/client/connection/src/index.ts) +Source: [`packages/client/connection/src/index.ts:20`](../packages/client/connection/src/index.ts) ## `@deepseek-ai/dsh-client-hmr` diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index af7d3d590d..682314605d 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: 5001da2458ea3470659f5983dffc8de832aadeac -README.zh.md: 47e745964e4087c6ccc59aae5bbfba69f96480e4 +README.md: c8b7c4787cbcbf6a202fb944459a589fcadd7c8d +README.zh.md: f36cb4c4c6856089751e4492eb6e4b8e22abde56 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 5001da2458..c8b7c4787c 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -2,22 +2,14 @@ English | [中文](README.zh.md) -Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The node half owns both browser-facing prefixes — `/api` for RPC and `/f` for workspace-file reads — behind one trust fence. The `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. +Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. ## /api browser-trust fence The node half guards every request under `/api` before bridging (`src/api-request-trust.ts`). Every request — browser-marked or not — must present a `Host` that is a loopback authority or matches a `trustedHosts` entry: exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense). There is deliberately no shortcut for requests without browser markers: 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 still be a rebound browser read with a readable response, and Host is the one header rebinding cannot forge; non-browser clients pass the same fence via loopback, the CLI-derived LAN IP literals, or a declared authority. When markers are present, an attached `Origin` must equal the Host authority, and an explicit `sec-fetch-site: cross-site` marker is refused. A `trustedHosts` entry that is not a bare, canonical `host[:port]` authority — one WHATWG parsing reads back exactly as written — fails the plugin load loudly: parsing would otherwise quietly authorize the hostname inside `harness.internal/path`, or broaden a dangling-colon or zero-padded port to an any-port grant. Failures answer plain 403 before any RPC dispatch. A non-loopback (`--host 0.0.0.0`) deployment therefore needs its serving authorities trusted: the dsh CLI derives the machine's LAN IP literals itself and its `--trusted-host` flag declares named ones, so `trustedHosts` in cordis.yml is for compositions the CLI does not boot. The fence is deliberately not an authentication layer — reachability policy stays with the webserver binding, and auth remains deferred work. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md). -## /f workspace-file reads - -The node half also serves one file at a time out of a Session's workspace under `/f/<sessionId>/<segments…>`, so a produced deliverable is reachable from the page that reported it — an `http` page cannot follow a `file://` link, and a browser that is not on the Host machine has no such path anyway. The segments ride the URL rather than a query parameter so a served document's relative references resolve to its siblings. The request names a Session and the gateway names that Session's directory (`ApiProxy.workspaceRootOf`, which answers from a live agent's header or the persistence store and never resumes an agent to serve a file); this package reads the authority rather than the core services, because holding their host-side Context declarations would merge them over the browser runtime's own. The URL shape itself lives with the other browser-importable contract surfaces, in [`@deepseek-ai/dsh-host-apiproxy/api`](../../host/apiproxy/README.md), so the browser half that builds a URL and this half that parses one share a single encoding decision. Both the cwd and the resolved target go through `realpath` before comparison, so a symlink inside the workspace pointing out of it is refused by its target rather than its name; traversal spellings are refused earlier still, at parse time, before any filesystem call. Reads stream (no request buffers a file), answer `GET`/`HEAD` only, and carry `nosniff` with `no-store`. Extensions outside the served content-type table are typed `text/plain` rather than offered as a download, because a workspace read is a request to see a file. - -Workspace files are served from their own port, and therefore their own origin. That port is the isolation: a workspace file is not necessarily agent-authored — a read row makes every file in a cloned repository openable — so an active document served beside `/api` would have its script pass the browser-trust fence into every method, the loopback-pinned settings and credential plane included. A different origin closes that without touching the document: a preview keeps `localStorage`, cookies, and its own `fetch`, while a call to the API is cross-origin and refused twice over — by the fence's Origin check and by CORS. The alternative, `Content-Security-Policy: sandbox`, buys the same boundary by taking the document's origin away entirely, which measurably breaks the pages this route exists to show (a page that reads `localStorage` throws on load, and every listener declared after that line in the same script never binds). The listener binds the same host as the API, so a client that can reach the app can reach its previews; it answers the `/f` prefix and nothing else — no index, no SPA fallback, no API — and its port is published into the index page as `window.__DSH_FILES_PORT__`, which the browser half reads to address it. The same trust fence gates it, so a `trustedHosts` deployment serves workspace files exactly where it serves ordinary reads. - ## Keyless fixture -A fixture page is served by no host, so no workspace-file port is published into it and `ConnectionHandle.fileUrl` answers `undefined` — a file-path row falls back to the Host opener rather than opening a dead tab. - Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival. Fixture content search preserves the production-facing `unicode61`-style case, diacritic, and token-phrase behavior and returns a match-centered snippet of at most 120 Unicode code points. ## Model Experience diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 47e745964e..f36cb4c4c6 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -2,18 +2,12 @@ [English](README.md) | 中文 -协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。node 半侧持有两条面向浏览器的前缀——`/api` 承载 RPC,`/f` 承载工作区文件读取——共用同一道信任 fence。`/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 +协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 ## /api 浏览器信任栅栏 node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust.ts`)。每个请求——无论是否带浏览器标记——`Host` 都必须是回环地址权威,或与某个 `trustedHosts` 条目匹配:带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御)。刻意不为无浏览器标记的请求开捷径:明文 HTTP 下浏览器的读取(EventSource、图片、导航——这些头只发给可信目标)既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求仍可能是被重绑页面发起的、响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;非浏览器客户端经由回环地址、CLI 推导的 LAN IP 字面量或已声明的权威通过同一道栅栏。当标记存在时,`Origin` 必须与 Host 权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯的、规范形 `host[:port]` 权威的 `trustedHosts` 条目——即 WHATWG 解析读回后与原文不完全一致的——会让插件加载大声失败:否则解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname,或把悬空冒号、补零端口放大成任意端口授权。失败在任何 RPC 分发之前以纯 403 应答。因此非回环(`--host 0.0.0.0`)部署需要让自己的服务权威被信任:dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏刻意不承担认证职责——可达性策略归 webserver 绑定配置,认证仍是延期工作。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。 -## /f 工作区文件读取 - -node 半侧还会在 `/f/<sessionId>/<segments…>` 下逐个提供某个 Session 工作区里的文件,让产出的交付物能从报告它的那个页面直接抵达——`http` 页面无法跟随 `file://` 链接,而不在 Host 机器上的浏览器本来也没有那条路径。段落走 URL 而非查询参数,是为了让所服务文档的相对引用能解析到它的同级文件。请求指名一个 Session,由网关指名该 Session 的目录(`ApiProxy.workspaceRootOf`,它从活跃 agent 的 header 或持久化存储作答,绝不会为了提供一个文件而恢复 agent);本包读取这个权威来源而不去够核心服务,因为持有它们的 host 侧 Context 声明会把它们盖到浏览器运行时自己的声明之上。URL 形状本身与其余浏览器可导入的契约面放在一起,位于 [`@deepseek-ai/dsh-host-apiproxy/api`](../../host/apiproxy/README.md),因此构造 URL 的浏览器半侧与解析 URL 的这一半共享同一个编码决定。cwd 与解析出的目标在比较前都要过 `realpath`,因此工作区内指向工作区外的符号链接会因其目标而被拒绝,而不是因其名字;穿越写法拒得更早,在解析期、任何文件系统调用之前。读取是流式的(没有请求会把文件缓冲起来),只应答 `GET`/`HEAD`,并带上 `nosniff` 与 `no-store`。所服务的内容类型表之外的扩展名一律按 `text/plain` 定型而非作为下载给出,因为工作区读取本就是一个“让我看看这个文件”的请求。 - -工作区文件由它自己的端口提供,因而拥有自己的源。那个端口就是隔离:工作区文件未必由 agent 撰写——一条 read 行就能让 clone 下来的仓库里任何文件变得可打开——因此与 `/api` 并排提供的活动文档,其脚本会带着浏览器信任 fence 通行到每一个方法,包括那些正因会改动设置与凭据而被钉在回环的方法。换一个源即可堵死这条,且不必动文档本身:预览保有 `localStorage`、cookie 与自己的 `fetch`,而对 API 的调用属于跨源,会被两道独立的关卡拒绝——fence 的 Origin 校验,以及 CORS。另一种做法 `Content-Security-Policy: sandbox` 用"干脆剥夺文档的源"换来同一条边界,而这经实测会破坏本路由存在的意义所在的那类页面(读 `localStorage` 的页面在加载时抛异常,同一 script 块中该行之后声明的所有监听器都不会绑定)。该监听器绑定与 API 相同的 host,因此能访问应用的客户端也能访问它的预览;它只应答 `/f` 前缀,别无其他——没有首页、没有 SPA 兜底、没有 API——其端口以 `window.__DSH_FILES_PORT__` 注入首页,由浏览器半侧读取来寻址。它由同一道信任 fence 把守,因此配置了 `trustedHosts` 的部署提供工作区文件的范围,与它提供普通读取的范围完全一致。 - ## 无密钥 fixture fixture 页面不由任何 host 提供,因此没有工作区文件端口注入其中,`ConnectionHandle.fileUrl` 应答 `undefined`——文件路径行会回退到 Host 打开器,而不是打开一个空标签页。 diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index f5fa3e34ea..0549fc1160 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2363,10 +2363,6 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { return Promise.resolve({ accepted: true }) }, - // The fixture has no filesystem behind its Sessions, so it names no - // directory for any of them; the /f route belongs to the node half, which - // a fixture page never reaches. - workspaceRootOf: () => Promise.resolve(undefined), } } diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 004b9fe286..e286157e46 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -4,9 +4,6 @@ * controller with its sinks. */ import type { Context } from 'cordis' -import { workspaceFileSegments, workspaceFileUrl } from '@deepseek-ai/dsh-host-apiproxy/api' -import type { SessionId } from '@deepseek-ai/dsh-session/types' -import { FILES_PORT_GLOBAL } from '../files-server.ts' import type { IApiClient } from './api.ts' import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts' import { FixtureApiClient } from './fixture.ts' @@ -59,19 +56,6 @@ export interface ConnectionHandle { * @returns stop handle for the loop. */ start(sinks: ConnectionSinks, config?: ConnectionConfig): { stop(): void } - /** - * Absolute URL serving one file out of a Session's workspace, on the - * transport's own workspace-file origin — the same hostname the page is - * reached by, a different port, so a served document is isolated from this - * API without being stripped of its own capabilities. - * @param sessionId - the Session whose cwd anchors the path. - * @param cwd - that Session's working directory, or `undefined` when unknown. - * @param path - the path a tool reported (absolute, or relative to `cwd`). - * @returns the URL, or `undefined` when the path lies outside the workspace - * (which this transport never serves) or when this page was not served by a - * host that published a workspace-file port (the fixture carrier). - */ - fileUrl(sessionId: SessionId, cwd: string | undefined, path: string): string | undefined } /** @@ -84,15 +68,6 @@ export function apply(ctx: Context): void { let started = false const handle: ConnectionHandle = { api, - fileUrl(sessionId, cwd, path) { - // Published by the node half's index tap; absent means no host is - // serving workspace files to this page (the keyless fixture lane). - const port = (globalThis as unknown as Record<string, unknown>)[FILES_PORT_GLOBAL] - if (typeof port !== 'number') return undefined - const segments = workspaceFileSegments(cwd, path) - if (segments === undefined) return undefined - return `${location.protocol}//${location.hostname}:${String(port)}${workspaceFileUrl(sessionId, segments)}` - }, start(sinks, config) { if (started) throw new Error('connection: the stream loop is already owned by another consumer') started = true diff --git a/packages/client/connection/src/files-server.ts b/packages/client/connection/src/files-server.ts deleted file mode 100644 index 73807c0810..0000000000 --- a/packages/client/connection/src/files-server.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * The workspace-file listener: a second loopback/LAN socket on the same host - * as the API, serving nothing but `/f`. - * - * The port is the isolation. A workspace file is not necessarily - * agent-authored — a read row makes every file in a cloned repository - * openable — so an active document must not be same-origin with `/api`, where - * its script would pass the browser-trust fence into every method, the - * loopback-pinned settings and credential plane included. A different port is - * a different origin, which the browser enforces for free: the document keeps - * `localStorage`, cookies, and its own `fetch`, while a call to the API is - * cross-origin and refused twice over — by the fence's Origin check and by - * CORS. The alternative, `Content-Security-Policy: sandbox`, buys the same - * boundary by taking the document's origin away entirely, which measurably - * breaks the pages this route exists to show. - */ - -import { createServer } from 'node:http' -import type { IncomingMessage, Server, ServerResponse } from 'node:http' -import type { AddressInfo } from 'node:net' -import { FILES_PATH } from '@deepseek-ai/dsh-host-apiproxy/api' -import { isTrustedApiRequest } from './api-request-trust.ts' -import { handleWorkspaceFile, type WorkspaceFileDeps } from './workspace-files.ts' - -/** A listening workspace-file server: its port, and the teardown that reaches quiescence. */ -export interface FilesServer { - /** The bound port (OS-assigned), which the browser half needs to address this origin. */ - port: number - /** Close the socket and destroy held connections; resolves once quiet. */ - close: () => Promise<void> -} - -/** - * Bind the workspace-file listener. - * @param host - the same bind host the API uses, so a client that can reach - * the app can reach its previews (a LAN deployment included). - * @param trustedHosts - the deployment's non-loopback serving authorities, - * applied through the same fence as `/api`. - * @param deps - the session-to-directory lookup reads are confined by. - * @param onSocketError - reports a post-listen socket error; without a - * listener node would raise it as an unhandled 'error' event. - * @returns the bound port and its disposer. - */ -export async function listenForWorkspaceFiles( - host: string, - trustedHosts: readonly string[], - deps: WorkspaceFileDeps, - onSocketError: (error: Error) => void, -): Promise<FilesServer> { - const handle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => { - if (!isTrustedApiRequest(req, trustedHosts)) { - res.writeHead(403) - res.end('forbidden') - return - } - /* v8 ignore next -- `?? '/'` arm: node:http always sets url on server requests. */ - const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname - // This origin serves one prefix and nothing else: no index, no SPA - // fallback, no API. Anything else is not here — answered before the method - // check, because a 405 would claim the resource exists. - if (pathname !== FILES_PATH && !pathname.startsWith(`${FILES_PATH}/`)) { - res.writeHead(404) - res.end() - return - } - if (req.method !== 'GET' && req.method !== 'HEAD') { - // RFC 9110 §15.5.6: a 405 names the methods the resource does support. - res.writeHead(405, { allow: 'GET, HEAD' }) - res.end() - return - } - await handleWorkspaceFile(req, res, deps) - } - - const server: Server = createServer((req, res) => { - handle(req, res).catch((error: unknown) => { - // A malformed request must not become an unhandled rejection that takes - // the process down; the API carrier guards its own handler the same way. - if (res.headersSent) { - res.destroy() - return - } - onSocketError(error instanceof Error ? error : new Error(String(error))) - res.writeHead(400) - res.end() - }) - }) - - await new Promise<void>((resolve, reject) => { - server.once('error', reject) - server.listen(0, host, () => { - server.off('error', reject) - server.on('error', onSocketError) - resolve() - }) - }) - - return { - port: (server.address() as AddressInfo).port, - // close + closeAllConnections: a held-open response would otherwise keep - // teardown waiting forever. - close: () => new Promise<void>((resolve) => { - server.close(() => { resolve() }) - server.closeAllConnections() - }), - } -} - -/** The global the node half hands its port to the browser half through. */ -export const FILES_PORT_GLOBAL = '__DSH_FILES_PORT__' - -/** - * Inject the workspace-file port into index.html, ahead of the shell bundle - * that reads it. A boot-time fact of the serving host, delivered the way the - * module graph is: synchronously on the page, so the first click on a produced - * file does not race a round trip. - * @param html - the index.html source. - * @param port - the bound workspace-file port. - * @returns the html with the port script injected. - */ -export function injectFilesPort(html: string, port: number): string { - const script = `<script>window.${FILES_PORT_GLOBAL} = ${String(port)}</script>` - const head = html.indexOf('<head>') - if (head !== -1) return `${html.slice(0, head + 6)}${script}${html.slice(head + 6)}` - /* v8 ignore next -- headless fixture pages may lack <head>; prepending keeps read-before-shell ordering. */ - return `${script}${html}` -} diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index f0a60bbfb9..03f8aaa257 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -1,16 +1,11 @@ -/** Host HTTP bridge for browser-client RPC and workspace-file reads. */ +/** Host HTTP bridge for browser-client RPC. */ import type { Context } from 'cordis' import z from 'schemastery' // Activates the httpServer Context merge used below. import type { WebRoute } from '@deepseek-ai/dsh-host-webserver' import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' -// The merge-free types subpath: pulling the session package's root into this -// client-registered program would merge the host `sessions` service over the -// browser runtime's own. -import type { SessionId } from '@deepseek-ai/dsh-session/types' import { API_PATH } from './api-path.ts' import { bridge } from './http-bridge.ts' -import { injectFilesPort, listenForWorkspaceFiles } from './files-server.ts' import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts' export { API_PATH } from './api-path.ts' @@ -66,17 +61,15 @@ const PRIVILEGED_METHODS = new Set([ ]) /** - * Mounts the API gateway and the workspace-file reads under the browser - * transport prefixes. Every request on either prefix passes the browser-trust - * fence first (DNS-rebinding and cross-site defense — - * [api-request-trust](./api-request-trust.ts)); privileged methods - * additionally pass it with an empty trust list, which pins them to loopback. + * Mounts the API gateway under the browser transport prefix. Every request on + * the prefix passes the browser-trust fence first (DNS-rebinding and + * cross-site defense — [api-request-trust](./api-request-trust.ts)); + * privileged methods additionally pass it with an empty trust list, which + * pins them to loopback. * @param ctx - Host plugin context. * @param config - resolved plugin config (schema defaults applied). - * @returns a promise settling once the workspace-file listener is bound and - * its port published — the page must never render before it can address one. */ -export async function apply(ctx: Context, config?: ConnectionConfig): Promise<void> { +export function apply(ctx: Context, config?: ConnectionConfig): void { // The Loader resolves schema defaults; hand-built test contexts may pass none. const trustedHosts = config?.trustedHosts ?? [] // Config boundary: a malformed entry fails the load loudly here rather than @@ -104,24 +97,4 @@ export async function apply(ctx: Context, config?: ConnectionConfig): Promise<vo } ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route') - // The gateway is the host's session authority: it answers where a Session's - // files live without this package reaching into the core services, which - // would merge their host-side Context declarations into the browser lane. - const cwdFor = (sessionId: string): Promise<string | undefined> => - ctx.apiProxy.workspaceRootOf(sessionId as SessionId) - // Workspace files get their own port, and therefore their own origin: an - // active document served beside `/api` would reach every method through the - // fence below. The listen is awaited inside the effect so the port is known - // before the index tap that publishes it can run. - await ctx.effect(async () => { - const files = await listenForWorkspaceFiles( - ctx.httpServer.host, trustedHosts, { cwdFor }, - (error) => { ctx.logger.error(error) }, - ) - const untap = ctx.httpServer.tapIndex(html => injectFilesPort(html, files.port)) - return async () => { - untap() - await files.close() - } - }, 'client-connection: /f listener') } diff --git a/packages/client/connection/src/workspace-files.ts b/packages/client/connection/src/workspace-files.ts deleted file mode 100644 index c934b14516..0000000000 --- a/packages/client/connection/src/workspace-files.ts +++ /dev/null @@ -1,164 +0,0 @@ -/** - * The read half of the web transport: streams one file out of a session's - * workspace so the browser can open what the agent just produced. The RPC - * gateway carries structured session state; this route carries bytes, which a - * JSON-RPC envelope cannot stream and a `file://` link cannot reach from an - * http page. - * - * Confinement is the whole contract: a request names a session, the session - * names its cwd, and nothing outside that realpath is ever served. The caller - * owns the browser-trust fence ([api-request-trust](./api-request-trust.ts)) — - * this module is reached only by requests that already passed it. - * - * Isolation is the listener's, not this module's: these responses carry no - * sandbox header because they are served from their own port, and therefore - * their own origin ([files-server](./files-server.ts)). A served document - * keeps `localStorage`, cookies, and its own `fetch`, while the API stays - * cross-origin to it. - */ - -import { createReadStream } from 'node:fs' -import { realpath, stat } from 'node:fs/promises' -import type { IncomingMessage, ServerResponse } from 'node:http' -import { extname, resolve, sep } from 'node:path' -import { pipeline } from 'node:stream/promises' -import { parseWorkspaceFilePath } from '@deepseek-ai/dsh-host-apiproxy/api' - -/** - * Content types served verbatim. Everything absent is `text/plain`, not - * `application/octet-stream`: a workspace read is a "show me what you made" - * gesture, and an unknown extension is far more often a source file to read - * than a binary to download. `nosniff` keeps that choice binding, so a - * mislabelled document can never be re-interpreted as HTML. - */ -const MIME: Record<string, string> = { - '.html': 'text/html; charset=utf-8', - '.htm': 'text/html; charset=utf-8', - '.xhtml': 'application/xhtml+xml', - '.svg': 'image/svg+xml', - '.css': 'text/css; charset=utf-8', - '.js': 'text/javascript; charset=utf-8', - '.mjs': 'text/javascript; charset=utf-8', - '.json': 'application/json', - '.pdf': 'application/pdf', - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.gif': 'image/gif', - '.webp': 'image/webp', - '.avif': 'image/avif', - '.ico': 'image/x-icon', - '.mp4': 'video/mp4', - '.webm': 'video/webm', - '.mp3': 'audio/mpeg', - '.wav': 'audio/wav', - '.wasm': 'application/wasm', -} - -const DEFAULT_MIME = 'text/plain; charset=utf-8' - -/** How the route learns which directory a session may serve from. */ -export interface WorkspaceFileDeps { - /** - * The session's absolute working directory. - * @param sessionId - the session named by the request path. - * @returns its cwd, or `undefined` when the id names no session this host serves. - */ - cwdFor: (sessionId: string) => Promise<string | undefined> -} - -function fail(res: ServerResponse, status: number): void { - res.writeHead(status) - res.end() -} - -/** - * Resolve one request's segments against a session cwd, refusing anything that - * leaves it. Both sides go through `realpath`, so a symlink inside the - * workspace pointing out of it is refused by its resolved target rather than - * its name. A component swapped between this resolution and the open below - * would still be followed; closing that window needs privileges that already - * imply workspace write access, which is strictly stronger than reading a - * workspace file, so the check stops here. - */ -async function confine(cwd: string, segments: readonly string[]): Promise<string | undefined> { - const root = await realpath(cwd) - // A filesystem root already ends in the separator; appending a second one - // would make every child fail the prefix test and 403 the whole workspace. - const prefix = root.endsWith(sep) ? root : root + sep - const real = await realpath(resolve(root, ...segments)) - return real.startsWith(prefix) ? real : undefined -} - -/** - * Serve one workspace-file request. The caller has already applied the - * browser-trust fence and rejected non-read methods. - * @param req - the request, read for its url and method only (no body). - * @param res - the response this function owns to completion. - * @param deps - the session-to-cwd lookup this host answers with. - */ -export async function handleWorkspaceFile( - req: IncomingMessage, - res: ServerResponse, - deps: WorkspaceFileDeps, -): Promise<void> { - /* v8 ignore next -- `?? '/'` arm: node:http always sets url on server requests. */ - const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname - const target = parseWorkspaceFilePath(pathname) - if (target === undefined) { - fail(res, 404) - return - } - const cwd = await deps.cwdFor(target.sessionId) - if (cwd === undefined) { - fail(res, 404) - return - } - - let file: string | undefined - let size: number - try { - file = await confine(cwd, target.segments) - if (file === undefined) { - fail(res, 403) - return - } - const info = await stat(file) - // A directory read has no answer here: the route serves files, and listing - // is the directory-picker capability's job, behind its own fence. - if (!info.isFile()) { - fail(res, 404) - return - } - size = info.size - } catch { - // Missing, unreadable, or a path whose ancestor is not a directory: all - // report as absent, so a probe cannot distinguish them. - fail(res, 404) - return - } - - const ext = extname(file).toLowerCase() - res.writeHead(200, { - 'content-type': MIME[ext] ?? DEFAULT_MIME, - 'content-length': String(size), - 'content-disposition': 'inline', - 'x-content-type-options': 'nosniff', - // Workspace files change under the agent's hands; a cached preview would - // show the previous turn's output after the next edit. - 'cache-control': 'no-store', - }) - if (req.method === 'HEAD') { - res.end() - return - } - try { - // pipeline (not pipe) so a client disconnect destroys the read stream: - // an abandoned preview must not leave a descriptor open. - await pipeline(createReadStream(file), res) - } catch { - // The status line is already out, so a mid-stream read failure or client - // disconnect can only end the response abruptly. - res.destroy() - } -} diff --git a/packages/client/connection/tests/client-apply.spec.ts b/packages/client/connection/tests/client-apply.spec.ts index 4b323182bb..f9fe1c1b71 100644 --- a/packages/client/connection/tests/client-apply.spec.ts +++ b/packages/client/connection/tests/client-apply.spec.ts @@ -8,11 +8,10 @@ import { apply, 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?: { search: string; protocol?: string; hostname?: string }; __DSH_FILES_PORT__?: number } +type Win = { location?: { search: string } } afterEach(() => { delete (globalThis as Win).location - delete (globalThis as Win).__DSH_FILES_PORT__ }) async function mount(): Promise<ConnectionHandle> { @@ -64,27 +63,4 @@ describe('connection client apply', () => { expect(seen.some(u => u.includes('/api/'))).toBe(true) }) - it('addresses a workspace file on the port the host published, and only inside the workspace', async () => { - const win = globalThis as Win - win.location = { search: '', protocol: 'http:', hostname: '192.168.1.5' } - win.__DSH_FILES_PORT__ = 4321 - const handle = await mount() - const session = 's-1' as never - // Same hostname the page was reached by — a LAN client must reach previews - // too — and the published port, which is what makes it another origin. - expect(handle.fileUrl(session, '/w/alpha', '/w/alpha/out/a b.html')) - .toBe('http://192.168.1.5:4321/f/s-1/out/a%20b.html') - // Outside the workspace there is nothing this transport may serve, which - // is the signal a caller falls back to openPath on. - expect(handle.fileUrl(session, '/w/alpha', '/etc/hosts')).toBeUndefined() - }) - - it('serves no file URL on a page no host published a port into', async () => { - const win = globalThis as Win - win.location = { search: '?fixture', protocol: 'http:', hostname: '127.0.0.1' } - const handle = await mount() - // The keyless fixture lane: no workspace-file origin exists, so the row - // falls back to the Host opener instead of opening a dead tab. - expect(handle.fileUrl('s-1' as never, '/w', 'a.txt')).toBeUndefined() - }) }) diff --git a/packages/client/connection/tests/files-server.spec.ts b/packages/client/connection/tests/files-server.spec.ts deleted file mode 100644 index 4a2618709b..0000000000 --- a/packages/client/connection/tests/files-server.spec.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** The workspace-file listener's own failure and publication paths. */ -import { describe, expect, it } from 'vitest' -import { FILES_PATH } from '@deepseek-ai/dsh-host-apiproxy/api' -import { injectFilesPort, listenForWorkspaceFiles } from '../src/files-server.ts' - -describe('workspace-file listener', () => { - it('answers 400 and reports the failure when the directory lookup throws', async () => { - const seen: Error[] = [] - const files = await listenForWorkspaceFiles( - '127.0.0.1', [], - { cwdFor: () => Promise.reject(new Error('store unavailable')) }, - (error) => { seen.push(error) }, - ) - try { - // A lookup failure is the host's problem, not a miss: it must not become - // an unhandled rejection, and it must not be reported as "not found". - const response = await fetch(`http://127.0.0.1:${String(files.port)}${FILES_PATH}/s-1/a.txt`) - expect(response.status).toBe(400) - expect(seen.map(error => error.message)).toEqual(['store unavailable']) - } finally { - await files.close() - } - }) - - it('closes idempotently and stops answering', async () => { - const files = await listenForWorkspaceFiles( - '127.0.0.1', [], { cwdFor: async () => undefined }, () => {}, - ) - const origin = `http://127.0.0.1:${String(files.port)}` - expect((await fetch(`${origin}${FILES_PATH}/s-1/a.txt`)).status).toBe(404) - await files.close() - await files.close() - await expect(fetch(`${origin}${FILES_PATH}/s-1/a.txt`)).rejects.toThrow() - }) -}) - -describe('injectFilesPort', () => { - it('publishes the port as the first script in head', () => { - const html = injectFilesPort('<html><head><title>x', 4321) - expect(html).toContain('') - // Ahead of anything the shell might read it from. - expect(html.indexOf('__DSH_FILES_PORT__')).toBeLessThan(html.indexOf('')) - }) -}) diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 2561a0846f..216484ad67 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -1,9 +1,6 @@ -/** Node half: registers the /api and /f prefix routes over the api gateway and the session workspaces. */ +/** Node half: registers the /api prefix route bridging to the api gateway. */ import { EventEmitter } from 'node:events' import { createServer, request as httpRequest } from 'node:http' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' import { Readable } from 'node:stream' import { Context } from 'cordis' import { describe, expect, it } from 'vitest' @@ -11,25 +8,17 @@ 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 { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver' -import { FILES_PATH } from '@deepseek-ai/dsh-host-apiproxy/api' import { API_PATH, apply, inject } from '../src/index.ts' /** Structural httpServer fake: the plugin only touches register(). */ -function fakeHttpServer( - routes: WebRoute[], - taps: ((html: string) => string)[] = [], -): Pick<HttpServerService, 'register' | 'tapIndex' | 'port' | 'host'> { +function fakeHttpServer(routes: WebRoute[]): Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> { return { register(route) { routes.push(route) return () => { routes.splice(routes.indexOf(route), 1) } }, - tapIndex(transform) { - taps.push(transform) - return () => { taps.splice(taps.indexOf(transform), 1) } - }, + tapIndex: () => () => {}, port: 0, - host: '127.0.0.1', } } @@ -60,47 +49,14 @@ function fakeResponse(): { response: ServerResponse; state: { status?: number; b return { response, state } } -/** The gateway stub: only the session-directory authority the /f route reads. */ -function fakeApiProxy(workspaces: Record<string, string> = {}): ApiProxy { - return { workspaceRootOf: async (id: string) => workspaces[id] } as unknown as ApiProxy -} - -async function mounted( - config?: { trustedHosts?: string[] }, - workspaces: Record<string, string> = {}, -): Promise<{ routes: WebRoute[]; taps: ((html: string) => string)[]; dispose: () => Promise<void> }> { +async function mounted(config?: { trustedHosts?: string[] }): Promise<{ routes: WebRoute[]; dispose: () => Promise<void> }> { const ctx = new Context() const routes: WebRoute[] = [] - const taps: ((html: string) => string)[] = [] - ctx.provide('httpServer', fakeHttpServer(routes, taps) as HttpServerService) - ctx.provide('apiProxy', fakeApiProxy(workspaces)) + ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService) + ctx.provide('apiProxy', {} as unknown as ApiProxy) const fiber = ctx.plugin({ inject: [...inject], apply }, config) await fiber.await() - return { routes, taps, dispose: () => fiber.dispose() } -} - -/** One raw GET whose Host header is spoofed (fetch forbids setting it). */ -function statusWithHost(origin: string, path: string, host: string): Promise<number> { - const url = new URL(origin) - return new Promise((resolve, reject) => { - const request = httpRequest( - { host: url.hostname, port: url.port, path, method: 'GET', headers: { host } }, - (response) => { - response.resume() - response.on('end', () => { resolve(response.statusCode ?? 0) }) - }, - ) - request.on('error', reject) - request.end() - }) -} - -/** The workspace-file origin the node half published into the index page. */ -function filesOrigin(taps: ((html: string) => string)[]): string { - const html = taps.reduce((acc, tap) => tap(acc), '<head></head>') - const port = /__DSH_FILES_PORT__ = (\d+)/.exec(html)?.[1] - if (port === undefined) throw new Error(`no workspace-file port was published: ${html}`) - return `http://127.0.0.1:${port}` + return { routes, dispose: () => fiber.dispose() } } describe('connection node half', () => { @@ -108,25 +64,17 @@ describe('connection node half', () => { const routes: WebRoute[] = [] const ctx = new Context() ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService) - ctx.provide('apiProxy', fakeApiProxy()) + 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) }) - it('registers the /api route and publishes a separate workspace-file origin, both removed with the fiber', async () => { - const { routes, taps, dispose } = await mounted() - // The API keeps one prefix on the shared server; workspace files get a - // port of their own, which is the origin boundary between them. + it('registers the /api prefix route and removes it with the fiber', async () => { + const { routes, dispose } = await mounted() expect(routes).toMatchObject([{ kind: 'prefix', path: API_PATH }]) - const origin = filesOrigin(taps) - expect(new URL(origin).port).not.toBe('') - expect((await fetch(`${origin}${FILES_PATH}/absent/x.txt`)).status).toBe(404) await dispose() expect(routes).toHaveLength(0) - expect(taps).toHaveLength(0) - // Disposal reaches quiescence: the socket is gone, not merely unrouted. - await expect(fetch(`${origin}${FILES_PATH}/absent/x.txt`)).rejects.toThrow() }) it('refuses an untrusted Host on any /api path before the bridge runs', async () => { @@ -187,48 +135,6 @@ describe('connection node half', () => { }) }) -describe('connection node half: the workspace-file origin', () => { - /** A workspace holding one file, torn down with the returned disposer. */ - async function workspace(): Promise<{ cwd: string; remove: () => Promise<void> }> { - const cwd = await mkdtemp(join(tmpdir(), 'dsh-node-half-')) - await writeFile(join(cwd, 'index.html'), '<h1>ok</h1>') - return { cwd, remove: () => rm(cwd, { recursive: true, force: true }) } - } - - it('applies the same browser-trust fence as /api, refuses writes, and serves nothing else', async () => { - const { taps, dispose } = await mounted() - const origin = filesOrigin(taps) - // Rebound Host: refused before any filesystem work, exactly as on /api. - // node's fetch refuses to set Host (a forbidden header), so the spoof goes - // through the raw client — the same parse the server really performs. - expect(await statusWithHost(origin, `${FILES_PATH}/s-1/index.html`, 'harness.example')).toBe(403) - const written = await fetch(`${origin}${FILES_PATH}/s-1/index.html`, { method: 'POST' }) - expect(written.status).toBe(405) - expect(written.headers.get('allow')).toBe('GET, HEAD') - // This origin is one route wide: no index, no SPA fallback, no API. - expect((await fetch(`${origin}/`)).status).toBe(404) - expect((await fetch(`${origin}${API_PATH}/session.list`, { method: 'POST' })).status).toBe(404) - await dispose() - }) - - it('confines reads to the directory the gateway names for that session', async () => { - const { cwd, remove } = await workspace() - const { taps, dispose } = await mounted(undefined, { 's-1': cwd }) - const origin = filesOrigin(taps) - const served = await fetch(`${origin}${FILES_PATH}/s-1/index.html`) - expect(served.status).toBe(200) - expect(await served.text()).toBe('<h1>ok</h1>') - // A served document keeps its own capabilities: the port is the boundary, - // so nothing here strips the document of its origin. - expect(served.headers.get('content-security-policy')).toBeNull() - // A session the gateway names no directory for has no workspace to confine - // against, so there is nothing to serve. - expect((await fetch(`${origin}${FILES_PATH}/s-absent/index.html`)).status).toBe(404) - await dispose() - await remove() - }) -}) - describe('connection node half over a real HTTP server', () => { /** Serve the registered prefix route from a real server and return its port. */ async function serve(routes: WebRoute[]): Promise<{ port: number; close: () => Promise<void> }> { diff --git a/packages/client/connection/tests/workspace-files.spec.ts b/packages/client/connection/tests/workspace-files.spec.ts deleted file mode 100644 index 8e33a6751b..0000000000 --- a/packages/client/connection/tests/workspace-files.spec.ts +++ /dev/null @@ -1,142 +0,0 @@ -/** - * Workspace-file reads over a real HTTP server and a real temporary - * workspace: confinement, content typing, and the sandbox header are wire - * facts, so they are asserted against responses Node actually produced. - */ -import { createServer } from 'node:http' -import type { AddressInfo } from 'node:net' -import type { ServerResponse } from 'node:http' -import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join, sep } from 'node:path' -import { Writable } from 'node:stream' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { FILES_PATH } from '@deepseek-ai/dsh-host-apiproxy/api' -import { handleWorkspaceFile } from '../src/workspace-files.ts' - -const SESSION = 's-1' - -let workspace: string -let outside: string -let origin: string -let close: () => Promise<void> - -beforeAll(async () => { - const root = await mkdtemp(join(tmpdir(), 'dsh-files-')) - workspace = join(root, 'workspace') - outside = join(root, 'outside') - await mkdir(join(workspace, 'out'), { recursive: true }) - await mkdir(outside, { recursive: true }) - await writeFile(join(workspace, 'index.html'), '<h1>产物</h1>') - await writeFile(join(workspace, 'notes.txt'), 'plain') - await writeFile(join(workspace, 'chart.svg'), '<svg xmlns="http://www.w3.org/2000/svg"/>') - await writeFile(join(workspace, 'model.safetensors'), 'unknown extension') - await writeFile(join(workspace, 'out', 'page.html'), '<p>nested</p>') - await writeFile(join(outside, 'secret.html'), 'SECRET') - await symlink(join(outside, 'secret.html'), join(workspace, 'escape.html')) - - const server = createServer((req, res) => { - void handleWorkspaceFile(req, res, { - // 'rooted' names the filesystem root, the separator-terminated realpath case. - cwdFor: async sessionId => sessionId === SESSION ? workspace : sessionId === 'rooted' ? sep : undefined, - }) - }) - await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve)) - origin = `http://127.0.0.1:${String((server.address() as AddressInfo).port)}` - close = () => new Promise<void>((resolve, reject) => { - server.close((error) => { - if (error === undefined || error === null) resolve() - else reject(error) - }) - }) - return async () => { await rm(root, { recursive: true, force: true }) } -}) - -afterAll(async () => { await close() }) - -function get(path: string, init?: RequestInit): Promise<Response> { - return fetch(`${origin}${path}`, init) -} - -describe('workspace file reads', () => { - it('serves a produced document with its own capabilities intact', async () => { - const response = await get(`${FILES_PATH}/${SESSION}/index.html`) - expect(response.status).toBe(200) - expect(await response.text()).toBe('<h1>产物</h1>') - expect(response.headers.get('content-type')).toBe('text/html; charset=utf-8') - // No isolation header: the listener's own port is the origin boundary, so - // a preview keeps localStorage and cookies (see files-server). - expect(response.headers.get('content-security-policy')).toBeNull() - expect(response.headers.get('x-content-type-options')).toBe('nosniff') - expect(response.headers.get('cache-control')).toBe('no-store') - expect(response.headers.get('content-disposition')).toBe('inline') - }) - - it('types SVG as a standalone document rather than sniffable bytes', async () => { - const svg = await get(`${FILES_PATH}/${SESSION}/chart.svg`) - expect(svg.headers.get('content-type')).toBe('image/svg+xml') - expect(svg.headers.get('x-content-type-options')).toBe('nosniff') - const text = await get(`${FILES_PATH}/${SESSION}/notes.txt`) - expect(text.headers.get('content-type')).toBe('text/plain; charset=utf-8') - }) - - it('serves a workspace rooted at a filesystem root, whose realpath already ends in a separator', async () => { - // `realpath('/')` is '/', so a naive `root + sep` prefix is '//' and every - // child of that workspace would 403. - const rooted = await fetch(`${origin}${FILES_PATH}/rooted${new URL(`file://${workspace}/notes.txt`).pathname}`) - expect(rooted.status).toBe(200) - expect(await rooted.text()).toBe('plain') - }) - - it('shows an unknown extension as text rather than downloading it', async () => { - const response = await get(`${FILES_PATH}/${SESSION}/model.safetensors`) - expect(response.status).toBe(200) - expect(response.headers.get('content-type')).toBe('text/plain; charset=utf-8') - }) - - it('serves a nested path, so a document reaches its own siblings', async () => { - const response = await get(`${FILES_PATH}/${SESSION}/out/page.html`) - expect(response.status).toBe(200) - expect(await response.text()).toBe('<p>nested</p>') - }) - - it('answers HEAD with the length and no body', async () => { - const response = await get(`${FILES_PATH}/${SESSION}/notes.txt`, { method: 'HEAD' }) - expect(response.status).toBe(200) - expect(response.headers.get('content-length')).toBe('5') - expect(await response.text()).toBe('') - }) - - it('refuses a symlink whose target leaves the workspace', async () => { - const response = await get(`${FILES_PATH}/${SESSION}/escape.html`) - expect(response.status).toBe(403) - expect(await response.text()).not.toContain('SECRET') - }) - - it('reports missing files, directories, and unknown sessions as absent', async () => { - expect((await get(`${FILES_PATH}/${SESSION}/nope.html`)).status).toBe(404) - expect((await get(`${FILES_PATH}/${SESSION}/out`)).status).toBe(404) - // A path whose ancestor is a file, not a directory. - expect((await get(`${FILES_PATH}/${SESSION}/notes.txt/child`)).status).toBe(404) - expect((await get(`${FILES_PATH}/s-other/index.html`)).status).toBe(404) - expect((await get(`${FILES_PATH}/${SESSION}`)).status).toBe(404) - }) -}) - -describe('workspace file streaming failures', () => { - it('tears the response down instead of rejecting when the body cannot be written', async () => { - // A client that goes away mid-stream must not surface as a handler - // rejection: the webserver's last-resort guard would log it and try to - // answer 400 on a response whose status line is already out. - const sink = new Writable({ - write(_chunk, _encoding, callback) { callback(new Error('socket gone')) }, - }) - const response = Object.assign(sink, { writeHead: () => response }) as unknown as ServerResponse - await expect(handleWorkspaceFile( - { url: `${FILES_PATH}/${SESSION}/index.html`, method: 'GET', headers: {} } as never, - response, - { cwdFor: async () => workspace }, - )).resolves.toBeUndefined() - expect(sink.destroyed).toBe(true) - }) -}) diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index a5827173a8..d389efe319 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -26,7 +26,6 @@ async function mount(): Promise<Bench> { const bench: Bench = { ctx, api, sinks: undefined, stopped: 0 } const handle: ConnectionHandle = { api, - fileUrl: () => undefined, start: (sinks) => { bench.sinks = sinks return { stop: () => { bench.stopped += 1 } } diff --git a/packages/client/runtime/tests/wire-events.spec.ts b/packages/client/runtime/tests/wire-events.spec.ts index a35983d890..fd7858d60c 100644 --- a/packages/client/runtime/tests/wire-events.spec.ts +++ b/packages/client/runtime/tests/wire-events.spec.ts @@ -20,7 +20,6 @@ async function mount(): Promise<Bench> { const bench: Bench = { ctx, sinks: undefined } const handle: ConnectionHandle = { api, - fileUrl: () => undefined, start: (sinks) => { bench.sinks = sinks return { stop: () => {} } diff --git a/packages/client/test-runtime/package.json b/packages/client/test-runtime/package.json index 6d7093a148..e892d9cd52 100644 --- a/packages/client/test-runtime/package.json +++ b/packages/client/test-runtime/package.json @@ -25,7 +25,6 @@ "vitest": "^4.1.8" }, "peerDependencies": { - "@deepseek-ai/dsh-client-connection": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-client-web-react": "^0.0.1", @@ -36,7 +35,6 @@ "react-dom": "^18.2.0" }, "devDependencies": { - "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-web-react": "workspace:^", diff --git a/packages/client/test-runtime/src/connection.ts b/packages/client/test-runtime/src/connection.ts deleted file mode 100644 index 5df5d5a053..0000000000 --- a/packages/client/test-runtime/src/connection.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** Test-owned connection face: the transport members features read off `ctx.connection`. */ -import { workspaceFileSegments, workspaceFileUrl } from '@deepseek-ai/dsh-host-apiproxy/api' -import type { ConnectionHandle, IApiClient, SessionId } from '@deepseek-ai/dsh-client-connection/client' - -/** - * Connection test double. Implements the same `ConnectionHandle` face features - * receive as `ctx.connection`, so a production face change breaks this double - * at compile time. The wire client is not modelled — a feature that needs one - * composes its own connection over a fake api client; this double exists for - * the transport facts features read synchronously, above all the - * workspace-file URL. - */ -export class TestConnection implements ConnectionHandle { - /** - * The workspace-file port the host would have published into the page. - * Unset — the default, and the keyless fixture lane's real state — makes - * {@link TestConnection.fileUrl} answer `undefined`, which is the signal a - * caller falls back to the Host opener on. - */ - filesPort: number | undefined - - /** The wire client; unused by this double's consumers and absent by construction. */ - readonly api: IApiClient = undefined as unknown as IApiClient - - /** - * Stream-loop starter (inert). - * @returns a stop handle that does nothing. - */ - start(): { stop(): void } { - return { stop: () => {} } - } - - /** - * Workspace-file URL, deriving exactly as production does so a feature test - * sees the real inside/outside-workspace split. - * @param sessionId - the Session whose cwd anchors the path. - * @param cwd - that Session's working directory. - * @param path - the path a tool reported. - * @returns the absolute URL on the workspace-file origin, or undefined when - * the path leaves the workspace or no port is published. - */ - fileUrl(sessionId: SessionId, cwd: string | undefined, path: string): string | undefined { - if (this.filesPort === undefined) return undefined - const segments = workspaceFileSegments(cwd, path) - if (segments === undefined) return undefined - return `http://localhost:${String(this.filesPort)}${workspaceFileUrl(sessionId, segments)}` - } -} diff --git a/packages/client/test-runtime/src/index.ts b/packages/client/test-runtime/src/index.ts index cdbdca75a2..5ef5350434 100644 --- a/packages/client/test-runtime/src/index.ts +++ b/packages/client/test-runtime/src/index.ts @@ -29,13 +29,11 @@ import type { } from '@deepseek-ai/dsh-client-ui-slots' import { registerDomSnapshotSerializer } from './snapshot.ts' import { TestSessions } from './sessions.ts' -import { TestConnection } from './connection.ts' import { TestWorkspaces } from './workspaces.ts' import type { Stabilizer } from './fixtures.ts' export { domSnapshotSerializer, registerDomSnapshotSerializer } from './snapshot.ts' export { FixtureSession, TestSessions } from './sessions.ts' -export { TestConnection } from './connection.ts' export { TestWorkspaces } from './workspaces.ts' export { conversationSnapshot, workspaceListState } from './fixtures.ts' export type { SessionBehaviorOverrides, SessionFixture, Stabilizer } from './fixtures.ts' @@ -177,8 +175,6 @@ export class SlotTestRuntime { readonly sessions: TestSessions /** Workspaces double (list observable, recorded intent actions). */ readonly workspaces: TestWorkspaces - /** The transport double features read as `ctx.connection`. */ - readonly connection: TestConnection private readonly stabilizer: Stabilizer = async (fn) => { await act(async () => { await fn() }) @@ -199,10 +195,8 @@ export class SlotTestRuntime { this.root = new TestRoot(slots, this.stabilizer) this.sessions = new TestSessions(this.stabilizer, ctx) this.workspaces = new TestWorkspaces(this.stabilizer) - this.connection = new TestConnection() ctx.provide('sessions', this.sessions) ctx.provide('workspaces', this.workspaces) - ctx.provide('connection', this.connection) // Capturing install: the production renderer does the rendering; the // wrapper only takes the host face for storeOf (no machinery copied). const renderer = createSlotRenderer() diff --git a/packages/client/test-runtime/tsconfig.json b/packages/client/test-runtime/tsconfig.json index 681bff474c..6a758c66f9 100644 --- a/packages/client/test-runtime/tsconfig.json +++ b/packages/client/test-runtime/tsconfig.json @@ -17,9 +17,6 @@ { "path": "../web-react" }, - { - "path": "../connection" - }, { "path": "../runtime" }, diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 446c2084b1..e85aa2ca30 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: 8c2075d615eccad1bbc7f5de1255ea4add69fab8 -README.zh.md: 634721b4248da75cbd4e81528340936a31ece28d +README.md: a9d4aadf4b0acc21f3909319724645c10f08bd31 +README.zh.md: 16be57c9ed8f8101a61eb704ba5e94491803d9b5 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 8c2075d615..a9d4aadf4b 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -14,7 +14,7 @@ Approvals take over the composer through the chain this package declares: `Appro Logged non-user messages render as a default-collapsed `上下文注入` disclosure. It shares the Tool calls header geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap, shows inline JSON for both `content` and `source`, and synthesizes no tool state, summary, or keyed toolview dispatch ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)). -Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file: one inside the session workspace opens in a new browser tab on the transport's workspace-file origin (`ConnectionHandle.fileUrl`), so a client that is not on the Host machine still sees it; one outside the workspace has no served URL and falls back to the Host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering. +Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is an underlined link — it reads as one at rest, not only on hover, because a path styled like the surrounding prose is an affordance nobody finds — and it opens the file through the Host (`host.openPath`, relative paths resolve against the session cwd). A document a browser renders opens in the default browser rather than the type's default application, so a produced page is shown rather than edited. The Host opens it on the Host's own machine: a client reached over a network sees nothing, which is the deliberate scope of this surface. Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering. A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 634721b424..16be57c9ed 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -12,7 +12,7 @@ 已记录的非用户消息渲染为默认折叠的 `上下文注入` 展开项。它通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 标题栏的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,并以内联 JSON 展示 `content` 和 `source`,且不会合成工具状态、摘要或键控 toolview 分发([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md))。 -通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击即打开文件:位于会话工作区之内的文件在新浏览器标签页打开,位于传输层的工作区文件源上(`ConnectionHandle.fileUrl`),因此不在 Host 机器上的客户端也能看到;工作区之外的文件没有可服务的 URL,回退到宿主操作系统的默认应用(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。 +通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是带下划线的链接——静止状态下就读得出是链接,而不只在悬停时,因为一条与周围正文同样样式的路径是没人会发现的交互——点击即经由 Host 打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。浏览器能渲染的文档会用默认浏览器打开,而不是该类型的默认应用,因此产出的页面是被展示而不是被编辑。Host 在它自己的机器上打开:经网络访问的客户端看不到任何东西,这是本交互面刻意划定的范围。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。 声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出按渲染意图开放——终端卡片与 web 卡片,各有自己的上限;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 55036600c6..88c09b5550 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -39,7 +39,6 @@ "clsx": "^2.0.0" }, "peerDependencies": { - "@deepseek-ai/dsh-client-connection": "^0.0.1", "@deepseek-ai/dsh-client-locale": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", @@ -51,7 +50,6 @@ "react": "^18.2.0" }, "devDependencies": { - "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index a65ce1d7a4..c67431e409 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -2,7 +2,6 @@ import type { Context } from 'cordis' import { resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots' import type { ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client' -import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' @@ -43,7 +42,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } /** Services required by the conversation plugin. */ -export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale', 'connection'] +export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale'] // Static no-session sources for the composer-bar hooks compartment: module // constants so the render side's per-source hook cache (observableHook) keeps @@ -276,16 +275,6 @@ export function apply(ctx: Context): void { }, openFile: (path) => { const cwd = sessions.list.getSnapshot().byId[sessionId]?.cwd - // A file inside the workspace opens in a new tab on the transport's - // workspace-file origin, so a browser that is not on the Host machine - // can still see what the agent produced. Anything outside it has no - // served URL and falls back to the Host's own opener, which is - // loopback-only by the /api trust fence. - const url = (ctx.get('connection') as ConnectionHandle).fileUrl(sessionId, cwd, path) - if (url !== undefined) { - window.open(url, '_blank', 'noopener,noreferrer') - return - } void workspaces.openPath(resolveToolPath(cwd, path)).catch(() => { // Host/OS open failures stay silent in the chat row; the native // app surfaces its own error dialog when the path is unusable. diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css index 81e41b066f..3e4fda9ebe 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css @@ -84,7 +84,10 @@ color: var(--dsw-alias-label-tertiary); } -/* File-tool path: same geometry as .summary; hover underline + pointer. */ +/* File-tool path: same geometry as .summary, but it must READ as a link. A + path styled exactly like the surrounding prose, underlined only on hover, is + an affordance nobody finds — the reported "I can't open what it made" was + this, not a missing capability. */ .fileLink { flex: 1 1 auto; min-width: 0; @@ -99,12 +102,16 @@ text-align: left; font-size: 14px; line-height: 24px; - color: var(--dsw-alias-label-tertiary); + color: var(--dsw-alias-label-secondary); + text-decoration: underline; + text-decoration-color: var(--dsw-alias-label-quaternary); + text-underline-offset: 3px; cursor: pointer; } .fileLink:hover { - text-decoration: underline; + color: var(--dsw-alias-label-primary); + text-decoration-color: currentColor; } /* Error row's collapsed summary: the failure's first line in the error color. */ diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index b9dbe0d6ad..cfbb0fdcbe 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -218,25 +218,13 @@ describe('conversation slot inject surface', () => { await b.runtime.dispose() }) - it('openFile (chat view face) opens a workspace file in a tab and falls back to the host opener outside it', async () => { + it('openFile (chat view face) resolves against session cwd and calls workspaces.openPath', async () => { const b = await bench() - // A host that publishes a workspace-file port: previews come from that - // origin, which is what keeps them off the API's. - b.runtime.connection.filesPort = 4321 - const open = vi.spyOn(window, 'open').mockReturnValue(null) const { injected } = b.chatViewSurface(ROOT) - // Inside the session cwd: served on the workspace-file origin, so a browser - // anywhere on the network sees the file the agent produced. injected.openFile('src/a.ts') - expect(open).toHaveBeenCalledWith(`http://localhost:4321/f/${ROOT}/src/a.ts`, '_blank', 'noopener,noreferrer') - expect(b.runtime.workspaces.calls.some(c => c.method === 'openPath')).toBe(false) - // Outside it there is no served URL, so the Host's own opener answers — - // resolved against the session cwd exactly as before. - injected.openFile('/etc/hosts') await vi.waitFor(() => { - expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['/etc/hosts'] }) + expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['/proj/src/a.ts'] }) }) - open.mockRestore() await b.runtime.dispose() }) diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index 2763702c0b..bafc6fe709 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -136,9 +136,6 @@ async function bench(snapshot: ConversationSnapshot) { openPath: vi.fn(async () => {}), } ctx.provide('workspaces', workspaces) - // The transport face the chat view reads its workspace-file URLs from. - const connection = { fileUrl: vi.fn((_s: unknown, _cwd: string | undefined, path: string) => `http://localhost:4321/f/s-1/${path}`) } - ctx.provide('connection', connection) ctx.provide('layout', layout) const locale = new LocaleService(ctx) ctx.provide('locale', locale) @@ -246,14 +243,12 @@ describe('run_code sub-calls through the real chat machinery', () => { subCall(12, parent, 2, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'), ]]]) const b = await bench(snapshotWith([codeResult(10, parent)], dispatches)) - const open = vi.spyOn(window, 'open').mockReturnValue(null) const view = mountApp(b.slots) view.getByText('notes/demo.txt').click() expect(b.layout.openDetails).not.toHaveBeenCalled() await vi.waitFor(() => { - expect(open).toHaveBeenCalledWith('http://localhost:4321/f/s-1/notes/demo.txt', '_blank', 'noopener,noreferrer') + expect(b.workspaces.openPath).toHaveBeenCalledWith('notes/demo.txt') }) - open.mockRestore() view.getByText('List notes').click() expect(b.layout.openDetails).not.toHaveBeenCalled() }) diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index 84cdd53eeb..eb48677d4f 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -119,17 +119,14 @@ describe('keyed toolview hole through the real machinery', () => { await b.runtime.dispose() }) - it('file-path clicks travel owner openFile → chat inject → the served workspace URL', async () => { + it('file-path clicks travel owner openFile → chat inject → workspaces.openPath', async () => { const b = await bench([toolResult(3, 'c1', 'read', '{"path":"src/a.ts"}')]) - b.runtime.connection.filesPort = 4321 - const open = vi.spyOn(window, 'open').mockReturnValue(null) const view = b.runtime.renderRoot() view.getByText('src/a.ts').click() expect(b.layout.openDetails).not.toHaveBeenCalled() await vi.waitFor(() => { - expect(open).toHaveBeenCalledWith(expect.stringContaining('/src/a.ts'), '_blank', 'noopener,noreferrer') + expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['src/a.ts'] }) }) - open.mockRestore() await b.runtime.dispose() }) diff --git a/packages/client/ui-conversation/tsconfig.json b/packages/client/ui-conversation/tsconfig.json index 33d45124b4..04b265bdd5 100644 --- a/packages/client/ui-conversation/tsconfig.json +++ b/packages/client/ui-conversation/tsconfig.json @@ -20,9 +20,6 @@ { "path": "../web-react" }, - { - "path": "../connection" - }, { "path": "../runtime" }, diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index b96bf528a7..27a1434e60 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: ee8e758a68f6efa3e363a36fcc9e8444e589ea40 -README.zh.md: 4ec3817e65543d6e248be9d902d0b74674f56e5a +README.md: 3c5a83a468b0cf9e596b8b13fafe40c409576fc5 +README.zh.md: f8533564575bf6b716f3fa7241ce47b8d4dd435f diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index ee8e758a68..3c5a83a468 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -36,8 +36,6 @@ The `command.*` and `skill.*` domains expose the host command registry and skill The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, and the section's `revision`. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads included (`settings.describe`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. -Two members of `ApiProxy` are deliberately not wire methods. `respond` is the client-response entry (four-quadrant model), and `workspaceRootOf` answers where a Session's files live for an in-process reader — a live agent's header first, then the persistence store, never a resume. It has no wire face: a browser learns a Session's cwd from `sessions.view`, and reaches a file through the web transport's own `/f` route, never by asking for a host path. That route's URL shape (`api/files.ts`: `FILES_PATH`, `workspaceFileSegments`, `workspaceFileUrl`, `parseWorkspaceFilePath`) lives here with the other browser-importable contract surfaces, so the browser half that builds a `/f` URL and the serving half that parses one cannot drift apart; the route itself belongs to [`dsh-client-connection`](../../client/connection/README.md). - ## Carrier layer (`/client` + root) `AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` is the isomorphic point: the full wire serialization/validation path with no network, used by `dsh -p` headless. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 4ec3817e65..f853356457 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -36,8 +36,6 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表,以及该分节的 `revision`。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;过期的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取:`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 -`ApiProxy` 上有两个成员刻意不是协议方法。`respond` 是客户端响应入口(四象限模型),`workspaceRootOf` 则为进程内读取方回答某个 Session 的文件位于何处——先看活跃 agent 的 header,再看持久化存储,绝不恢复会话。它没有协议面:浏览器从 `sessions.view` 得知 Session 的 cwd,并经由 web 传输自己的 `/f` 路由抵达文件,而不是靠索要一条宿主路径。该路由的 URL 形状(`api/files.ts`:`FILES_PATH`、`workspaceFileSegments`、`workspaceFileUrl`、`parseWorkspaceFilePath`)与其余浏览器可导入的契约面一同放在这里,因此构造 `/f` URL 的浏览器半侧与解析它的服务半侧不会彼此漂移;路由本身则属于 [`dsh-client-connection`](../../client/connection/README.md)。 - ## 载体层(`/client` + 根路径) `AbstractApiClient` 持有全部协议不变量:签发 rpcId、包装/解包信封、Zod 解析、SSE 帧解码、一元请求超时,以及按微任务批处理的信封观测(`subscribeEnvelopes`);平台子类只提供 `doFetch` 传输环节。`InProcessApiClient` 以 `toFetchHandler(api)` 为基础,是同构接点:它运行完整的协议序列化与校验路径而不经过网络,供 `dsh -p` headless 模式使用。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 63e1f0007c..4e506ed262 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -2290,20 +2290,5 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro pending.resolve(payload.answer) return Promise.resolve({ accepted: true }) }, - - async workspaceRootOf(sessionId: SessionId): Promise<string | undefined> { - // A live agent answers from its own header; otherwise the store answers, - // deliberately without resuming — reading a session's directory must not - // pull an agent up the way the cold RPC path does. - const live = ctx.agents.get(sessionId) - if (live !== undefined) return live.session.header.cwd - const persistence = ctx.get('sessionPersistence') - if (persistence === undefined) return undefined - // TODO(persistence/by-id): a full listing per lookup. Harmless while the - // caller is one preview open, but a served document with N relative - // sub-resources pays it N times; a by-id header read on the persistence - // seam would retire it. - return (await persistence.list()).find(meta => meta.id === sessionId)?.cwd - }, } } diff --git a/packages/host/apiproxy/src/api/files.ts b/packages/host/apiproxy/src/api/files.ts deleted file mode 100644 index b4ba01f29b..0000000000 --- a/packages/host/apiproxy/src/api/files.ts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * The `/f` workspace-file URL shape: the contract half of the web transport - * that carries bytes rather than RPC. The browser turns a tool's file path - * into a URL, the serving side turns that URL back into the segments below a - * session's cwd, and both read this one encoding decision so neither can drift - * into serving a path the other never meant. Pure string work with no Node and - * no DOM, like the rest of `api/` — the browser bundle inlines it. - * @module @deepseek-ai/dsh-host-apiproxy/api/files - */ - -/** - * Route prefix owning every workspace-file read (`/f/<sessionId>/<segments…>`). - * The path carries the segments verbatim rather than a query parameter so a - * served document's relative references (`./logo.png`) resolve to their - * siblings in the same workspace directory. - */ -export const FILES_PATH = '/f' - -/** One parsed workspace-file request: whose workspace, and where inside it. */ -export interface WorkspaceFileTarget { - /** The owning session, still an opaque string — the caller resolves it to a cwd. */ - sessionId: string - /** Decoded path segments below that session's cwd; never empty, never `.` or `..`. */ - segments: string[] -} - -/** A segment that survived decoding but would re-enter path resolution as more than one name. */ -function isPlainSegment(segment: string): boolean { - return segment !== '' && segment !== '.' && segment !== '..' - && !segment.includes('/') && !segment.includes('\\') && !segment.includes('\0') -} - -function decode(raw: string): string | undefined { - try { - return decodeURIComponent(raw) - } catch { - // A malformed %-escape is a request we cannot interpret, not a miss. - return undefined - } -} - -/** - * Express one tool-reported file path as segments below the session cwd. - * @param cwd - the session's working directory, or `undefined` when unknown. - * @param path - the path the tool reported (absolute, or relative to `cwd`). - * @returns the segments below `cwd`, or `undefined` when the path names - * something outside the workspace (which this route never serves) or resolves - * to the workspace directory itself. - */ -export function workspaceFileSegments(cwd: string | undefined, path: string): string[] | undefined { - const slashed = path.replace(/\\/g, '/') - const absolute = /^\/|^[A-Za-z]:\//.test(slashed) - let relative: string - if (absolute) { - if (cwd === undefined || cwd === '') return undefined - const root = cwd.replace(/\\/g, '/').replace(/\/+$/, '') - if (!slashed.startsWith(`${root}/`)) return undefined - relative = slashed.slice(root.length + 1) - } else { - relative = slashed - } - const segments = relative.split('/').filter(segment => segment !== '' && segment !== '.') - if (segments.length === 0 || segments.some(segment => !isPlainSegment(segment))) return undefined - return segments -} - -/** - * Build the origin-relative URL serving one workspace file. - * @param sessionId - the session whose cwd anchors the path. - * @param segments - segments below that cwd, as {@link workspaceFileSegments} returns them. - * @returns the `/f/…` URL, resolved by the browser against the serving origin. - */ -export function workspaceFileUrl(sessionId: string, segments: readonly string[]): string { - const encoded = segments.map(segment => encodeURIComponent(segment)).join('/') - return `${FILES_PATH}/${encodeURIComponent(sessionId)}/${encoded}` -} - -/** - * Parse a request pathname back into the session and segments it names. - * @param pathname - the request's raw (still percent-encoded) pathname. - * @returns the target, or `undefined` when the pathname is not a well-formed - * workspace-file read — including every traversal shape, which is refused here - * before any filesystem call rather than being resolved and then judged. - */ -export function parseWorkspaceFilePath(pathname: string): WorkspaceFileTarget | undefined { - if (!pathname.startsWith(`${FILES_PATH}/`)) return undefined - const [rawSession, ...rawSegments] = pathname.slice(FILES_PATH.length + 1).split('/') - if (rawSession === undefined || rawSegments.length === 0) return undefined - const sessionId = decode(rawSession) - if (sessionId === undefined || sessionId === '') return undefined - const segments: string[] = [] - for (const raw of rawSegments) { - const segment = decode(raw) - if (segment === undefined || !isPlainSegment(segment)) return undefined - segments.push(segment) - } - return { sessionId, segments } -} diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index 227e26264e..c97cd33e1b 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -15,9 +15,6 @@ import type { SettingsApi } from './settings.ts' import type { CredentialsApi } from './credentials.ts' import type { LlmApi } from './llm.ts' import type { ClientResponse, RpcReceipt } from './rpc.ts' -// The merge-free types subpath: api/ is imported from the browser lane, where -// the host session service must not merge over the client runtime's own. -import type { SessionId } from '@deepseek-ai/dsh-session/types' /** Root interface of the unified API surface. New client-request domain = one new file pair + one field here + one map row. */ export interface ApiProxy { @@ -33,17 +30,6 @@ export interface ApiProxy { llm: LlmApi /** Response entry for server-requests (client-response, echoing their rpcId); not a domain method (four-quadrant model). */ respond(message: ClientResponse): Promise<RpcReceipt> - /** - * The directory a Session's files may be read from — the same `cwd` the - * session summaries carry, in non-envelope form for an in-process reader. - * Not a domain method: it has no wire face, because a browser learns a - * Session's cwd from `sessions.view` and a file it may read from the web - * transport's own `/f` route, never by asking for a host path. - * @param sessionId - the Session to locate. - * @returns its absolute working directory, or `undefined` when this host - * serves no such Session. Resolving one never resumes an agent. - */ - workspaceRootOf(sessionId: SessionId): Promise<string | undefined> } // ---- Domain interfaces and payload entities ---- @@ -63,9 +49,6 @@ export type { CredentialsApi, CredentialView } from './credentials.ts' export type { ConfigurableProviderView, LlmApi } from './llm.ts' export type { ApprovalResponsePayload } from './approvals.ts' -// ---- Workspace-file URL shape (the transport's byte-carrying half) ---- -export { FILES_PATH, workspaceFileSegments, workspaceFileUrl, parseWorkspaceFilePath } from './files.ts' -export type { WorkspaceFileTarget } from './files.ts' export type { QuestionResponsePayload } from './questions.ts' // ---- Message layer: narrow forms (domain-signature view) ---- diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index f6dec19420..339b1e777d 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -64,7 +64,6 @@ export class ApiProxyService extends Service implements ApiProxy { readonly llm: ApiProxy['llm'] readonly events: ApiProxy['events'] readonly respond: ApiProxy['respond'] - readonly workspaceRootOf: ApiProxy['workspaceRootOf'] constructor(ctx: Context, config: Config) { super(ctx, 'apiProxy') @@ -88,7 +87,6 @@ export class ApiProxyService extends Service implements ApiProxy { // createApiProxy returns closures (no `this` capture); bind only satisfies // the unbound-method lint without changing behavior. this.respond = api.respond.bind(api) - this.workspaceRootOf = api.workspaceRootOf.bind(api) } } diff --git a/packages/host/apiproxy/src/native-path-opener.ts b/packages/host/apiproxy/src/native-path-opener.ts index a4fbbaa72e..444cf3d408 100644 --- a/packages/host/apiproxy/src/native-path-opener.ts +++ b/packages/host/apiproxy/src/native-path-opener.ts @@ -1,5 +1,16 @@ -/** Cross-platform open-with-default-application used by the local GUI carrier. */ +/** + * Cross-platform open-with-default-application used by the local GUI carrier. + * + * A document a browser RENDERS is opened with the user's default browser + * rather than the default application for its type, when the platform can name + * one: a developer who binds `.html` to an editor would otherwise click a + * produced page and get source code. The contract is uniform — prefer the + * default browser, fall back to the default application — while how completely + * a platform can answer "which browser" differs, and every failure falls back + * rather than surfacing. + */ +import { extname } from 'node:path' import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command' /** Testable command boundary; native implementations never invoke a shell. */ @@ -9,6 +20,60 @@ export type PathOpenerRunner = NativeCommandRunner export interface PathOpenerInternals { platform?: NodeJS.Platform run?: PathOpenerRunner + /** Environment the linux browser convention reads; defaults to the process env. */ + env?: NodeJS.ProcessEnv +} + +/** 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<boolean> { + 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 } /** PowerShell single-quoted literal (doubles embedded quotes). */ @@ -17,10 +82,11 @@ function powershellLiteral(path: string): string { } /** - * Open a filesystem path with the operating system's default application. + * 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 and runner seam for deterministic tests. + * @param internals - platform, environment, and runner seam for deterministic tests. */ export async function openNativePath( path: string, @@ -29,6 +95,10 @@ export async function openNativePath( ): Promise<void> { const platform = internals.platform ?? process.platform const run = internals.run ?? runNativeCommand + const env = internals.env ?? process.env + + if (BROWSER_DOCUMENTS.has(extname(path).toLowerCase()) + && await openInBrowser(path, signal, platform, run, env)) return if (platform === 'darwin') { await run('open', [path], signal) diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index cc30e5dee2..da05a4cd9b 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -62,11 +62,7 @@ function stubAgent(session: Session): Agent { async function harness( workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))), picker: DirectoryPickerCapability = { kind: 'native', pick: async () => null }, - extras: { - openPath?: (path: string, signal: AbortSignal) => Promise<void> - /** Store contents behind the gateway, or 'absent' for a composition with no persistence at all. */ - persisted?: { id: SessionId; cwd?: string }[] | 'absent' - } = {}, + extras: { openPath?: (path: string, signal: AbortSignal) => Promise<void> } = {}, ) { const ctx = new Context() await ctx.plugin(SessionStore) @@ -77,10 +73,7 @@ async function harness( const storageDomain = new DomainFacility(ctx, { backend: 'memory', routes: {} }) ctx.storage.mount('domain', storageDomain) ctx.provide('storageDomain', storageDomain) - if (extras.persisted !== 'absent') { - const persisted = extras.persisted ?? [] - ctx.provide('sessionPersistence', { list: () => Promise.resolve(persisted) } as never) - } + ctx.provide('sessionPersistence', { list: () => Promise.resolve([]) } as never) await ctx.plugin(WorkspaceRegistry) const factory: AgentFactory = { @@ -251,27 +244,6 @@ describe('host.openPath', () => { }) }) -describe('workspaceRootOf', () => { - it('answers from the live agent, then the store, and names nothing for an unknown session', async () => { - const { api, workspaceRoot } = await harness(undefined, undefined, { - persisted: [{ id: 's-cold' as SessionId, cwd: '/w/cold' }], - }) - const created = await api.sessions.create(request({ cwd: workspaceRoot })) - const sessionId = (created.result as { ok: true; value: { sessionId: SessionId } }).value.sessionId - // Live: the agent's own header, no store read involved. - await expect(api.workspaceRootOf(sessionId)).resolves.toBe(workspaceRoot) - // Not live: the store answers, and the lookup never resumes an agent — - // this harness's factory throws on resume, so a resuming lookup would fail. - await expect(api.workspaceRootOf('s-cold' as SessionId)).resolves.toBe('/w/cold') - await expect(api.workspaceRootOf('s-absent' as SessionId)).resolves.toBeUndefined() - }) - - it('names nothing at all when the host keeps no session store', async () => { - const { api } = await harness(undefined, undefined, { persisted: 'absent' }) - await expect(api.workspaceRootOf('s-any' as SessionId)).resolves.toBeUndefined() - }) -}) - describe('workspace.create', () => { it('serializes concurrent names and rejects the duplicate', async () => { const { api, workspaceRoot } = await harness() diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 2299949890..6307dfe8f9 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -108,8 +108,6 @@ function scriptedApi(overrides: { }, events: { mux: () => empty<MuxFrame>(), host: () => empty<HostFrame>(), ...overrides.events }, respond: overrides.respond ?? (() => Promise.resolve({ accepted: false as const, reason: 'not-pending' as const })), - // No wire face, so the handler map never reaches it. - workspaceRootOf: () => Promise.resolve(undefined), } } diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index dac49a1234..ef111afe12 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -233,8 +233,6 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra async respond(message: ClientResponse): Promise<RpcReceipt> { return message.rpcId === 'known' ? { accepted: true } : { accepted: false, reason: 'not-pending' } }, - // No wire face, so the carrier never reaches it. - workspaceRootOf: () => Promise.resolve(undefined), } } diff --git a/packages/host/apiproxy/tests/files-path.spec.ts b/packages/host/apiproxy/tests/files-path.spec.ts deleted file mode 100644 index df309a4783..0000000000 --- a/packages/host/apiproxy/tests/files-path.spec.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** The /f URL shape: one encoding decision, asserted from both ends. */ -import { describe, expect, it } from 'vitest' -import { - FILES_PATH, parseWorkspaceFilePath, workspaceFileSegments, workspaceFileUrl, -} from '../src/api/files.ts' - -describe('workspaceFileSegments', () => { - it('keeps a relative path as its own segments', () => { - expect(workspaceFileSegments('/w', 'out/index.html')).toEqual(['out', 'index.html']) - expect(workspaceFileSegments(undefined, 'index.html')).toEqual(['index.html']) - expect(workspaceFileSegments('/w', './a/./b.txt')).toEqual(['a', 'b.txt']) - }) - - it('strips the cwd prefix from an absolute path inside the workspace', () => { - expect(workspaceFileSegments('/w', '/w/a/b.html')).toEqual(['a', 'b.html']) - // A trailing separator on the cwd must not shift the split. - expect(workspaceFileSegments('/w/', '/w/a.html')).toEqual(['a.html']) - }) - - it('reads Windows paths on either separator', () => { - expect(workspaceFileSegments('C:\\w', 'C:\\w\\a\\b.html')).toEqual(['a', 'b.html']) - expect(workspaceFileSegments('C:/w', 'C:\\w\\a.html')).toEqual(['a.html']) - }) - - it('refuses everything the route would not serve', () => { - // Absolute, but not under this workspace. - expect(workspaceFileSegments('/w', '/etc/hosts')).toBeUndefined() - // A sibling directory sharing the cwd's name prefix is not inside it. - expect(workspaceFileSegments('/w', '/workspace-other/a')).toBeUndefined() - // Absolute with no cwd to anchor against. - expect(workspaceFileSegments(undefined, '/w/a.html')).toBeUndefined() - expect(workspaceFileSegments('', '/w/a.html')).toBeUndefined() - // Traversal, in either spelling. - expect(workspaceFileSegments('/w', '../secret')).toBeUndefined() - expect(workspaceFileSegments('/w', 'a/../../secret')).toBeUndefined() - // The workspace directory itself is not a file. - expect(workspaceFileSegments('/w', '/w')).toBeUndefined() - expect(workspaceFileSegments('/w', '.')).toBeUndefined() - }) -}) - -describe('workspaceFileUrl', () => { - it('percent-encodes each segment but keeps the separators structural', () => { - expect(workspaceFileUrl('s-1', ['out', 'a b.html'])).toBe(`${FILES_PATH}/s-1/out/a%20b.html`) - expect(workspaceFileUrl('s/1', ['a#b.html'])).toBe(`${FILES_PATH}/s%2F1/a%23b.html`) - }) -}) - -describe('parseWorkspaceFilePath', () => { - it('round-trips what the browser half builds', () => { - const url = workspaceFileUrl('s-1', ['out', 'a b.html']) - expect(parseWorkspaceFilePath(url)).toEqual({ sessionId: 's-1', segments: ['out', 'a b.html'] }) - }) - - it('refuses malformed, prefix-foreign, and traversal pathnames', () => { - expect(parseWorkspaceFilePath('/api/session.list')).toBeUndefined() - expect(parseWorkspaceFilePath(FILES_PATH)).toBeUndefined() - // Session named but no file below it. - expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1`)).toBeUndefined() - expect(parseWorkspaceFilePath(`${FILES_PATH}//a.html`)).toBeUndefined() - // Traversal is refused at parse time, before any filesystem call. - expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/../etc/hosts`)).toBeUndefined() - expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/a/./b`)).toBeUndefined() - expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/a//b`)).toBeUndefined() - // A separator smuggled through percent-encoding stays one segment's problem. - expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/a%2F..%2Fb`)).toBeUndefined() - expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/a%5Cb`)).toBeUndefined() - expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/a%00b`)).toBeUndefined() - // Malformed percent-escapes are uninterpretable, not a miss to resolve. - expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/a%zz`)).toBeUndefined() - expect(parseWorkspaceFilePath(`${FILES_PATH}/%zz/a.html`)).toBeUndefined() - expect(parseWorkspaceFilePath(`${FILES_PATH}//`)).toBeUndefined() - }) -}) diff --git a/packages/host/apiproxy/tests/native-path-opener.spec.ts b/packages/host/apiproxy/tests/native-path-opener.spec.ts index c8622002e2..f56ff1c66b 100644 --- a/packages/host/apiproxy/tests/native-path-opener.spec.ts +++ b/packages/host/apiproxy/tests/native-path-opener.spec.ts @@ -80,3 +80,96 @@ describe('native path opener', () => { }) }) }) + +describe('browser-renderable documents', () => { + const LS_PLIST = `{ + LSHandlers = ( + { + LSHandlerPreferredVersions = { + LSHandlerRoleAll = "-"; + }; + LSHandlerRoleAll = "com.google.chrome"; + LSHandlerURLScheme = https; + } + ); +}` + + it('opens a page with the default browser rather than the .html handler on darwin', async () => { + const calls: { command: string; args: readonly string[] }[] = [] + const run = async (command: string, args: readonly string[]) => { + calls.push({ command, args }) + return { stdout: command === 'defaults' ? LS_PLIST : '', stderr: '' } + } + await openNativePath('/w/page.html', new AbortController().signal, { platform: 'darwin', run }) + // A developer who bound .html to an editor still gets a rendered page. + expect(calls.map(c => [c.command, ...c.args])).toEqual([ + ['defaults', 'read', 'com.apple.LaunchServices/com.apple.launchservices.secure'], + ['open', '-b', 'com.google.chrome', '/w/page.html'], + ]) + }) + + it('leaves every other document to the default application', async () => { + const calls: string[][] = [] + const run = async (command: string, args: readonly string[]) => { + calls.push([command, ...args]) + return { stdout: '', stderr: '' } + } + await openNativePath('/w/report.md', new AbortController().signal, { platform: 'darwin', run }) + // No LaunchServices read at all: markdown is not a browser document. + expect(calls).toEqual([['open', '/w/report.md']]) + }) + + it('falls back to the default application when no browser can be named', async () => { + // LaunchServices has no https record (a fresh account), so the system's + // own content-type choice is the best answer available. + const calls: string[][] = [] + const run = async (command: string, args: readonly string[]) => { + calls.push([command, ...args]) + if (command === 'defaults') throw new Error('domain not found') + return { stdout: '', stderr: '' } + } + await openNativePath('/w/page.html', new AbortController().signal, { platform: 'darwin', run }) + expect(calls).toEqual([ + ['defaults', 'read', 'com.apple.LaunchServices/com.apple.launchservices.secure'], + ['open', '/w/page.html'], + ]) + + // A record without an https handler is the same answer. + const bare: string[][] = [] + await openNativePath('/w/page.html', new AbortController().signal, { + platform: 'darwin', + run: async (command, args) => { + bare.push([command, ...args]) + return { stdout: '{ LSHandlers = ( ); }', stderr: '' } + }, + }) + expect(bare[1]).toEqual(['open', '/w/page.html']) + }) + + it('honors $BROWSER on linux and leaves windows to its association', async () => { + const linux: string[][] = [] + await openNativePath('/w/page.html', new AbortController().signal, { + platform: 'linux', + env: { BROWSER: 'firefox' }, + run: async (command, args) => { linux.push([command, ...args]); return { stdout: '', stderr: '' } }, + }) + expect(linux).toEqual([['firefox', '/w/page.html']]) + + // Unset $BROWSER: xdg-open's association is the fallback. + const bare: string[][] = [] + await openNativePath('/w/page.html', new AbortController().signal, { + platform: 'linux', + env: {}, + run: async (command, args) => { bare.push([command, ...args]); return { stdout: '', stderr: '' } }, + }) + expect(bare).toEqual([['xdg-open', '/w/page.html']]) + + // Windows names no browser without the UserChoice registry. + const win: string[][] = [] + await openNativePath('C:\\w\\page.html', new AbortController().signal, { + platform: 'win32', + run: async (command, args) => { win.push([command, ...args]); return { stdout: '', stderr: '' } }, + }) + expect(win[0]?.[0]).toBe('powershell.exe') + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5fb7df52ad..7073f04e4b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1183,9 +1183,6 @@ importers: specifier: ^4.1.8 version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) devDependencies: - '@deepseek-ai/dsh-client-connection': - specifier: workspace:^ - version: link:../connection '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime @@ -1266,9 +1263,6 @@ importers: specifier: ^2.0.0 version: 2.1.1 devDependencies: - '@deepseek-ai/dsh-client-connection': - specifier: workspace:^ - version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale diff --git a/tsconfig.host.json b/tsconfig.host.json index 9de5b51da0..89f57e364a 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -38,7 +38,7 @@ "apps/web/tests/access-confirmation.e2e.ts", "apps/web/tests/shipped-composition.e2e.ts", "apps/web/tests/startup-auto-selection.e2e.ts", - "apps/web/tests/workspace-file-open.e2e.ts", + "apps/web/tests/produced-files.e2e.ts", "apps/cli/tests/**/*.ts", "examples/*/src/**/*.ts", "examples/*/start.ts", From f22cacc63b7c503cdde6915ba6c21b98774e8cfe Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:49:21 +0800 Subject: [PATCH 010/104] fix: advance resolving issue status from PRs --- ...-04-forward-only-pr-issue-status.i18n.yaml | 6 +++ ...2026-08-04-forward-only-pr-issue-status.md | 39 ++++++++++++++++ ...6-08-04-forward-only-pr-issue-status.zh.md | 39 ++++++++++++++++ .github/issue-management/policy.mjs | 32 ++++++++++---- .github/issue-management/policy.test.mjs | 44 +++++++++++++++++++ package.json | 1 + scripts/run-gates.ts | 3 ++ 7 files changed, 155 insertions(+), 9 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.md create mode 100644 .agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.zh.md diff --git a/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.i18n.yaml b/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.i18n.yaml new file mode 100644 index 0000000000..1b704da8f1 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.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/process/2026-08-04-forward-only-pr-issue-status.md +2026-08-04-forward-only-pr-issue-status.md: dd567707bc7fccd0a631943ab3ffd2838a7f2f76 +2026-08-04-forward-only-pr-issue-status.zh.md: f19cceafbde074d298a7c7f27829c8ab919f00b6 diff --git a/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.md b/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.md new file mode 100644 index 0000000000..dd567707bc --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.md @@ -0,0 +1,39 @@ +# Agent Note: Forward-only PR-to-Issue status projection + +Status: implemented + +English | [中文](2026-08-04-forward-only-pr-issue-status.zh.md) + +## Problem + +The Issue Project status represents the phase of the work, while an exact same-repository resolving keyword establishes the authoritative PR-to-Issue relationship. Restricting lifecycle advancement to Issues already in `Ready` leaves an Issue in `Inbox` or `Backlog` after implementation has demonstrably started. Requiring otherwise valid PR metadata before projecting the phase also conflates policy compliance with the work's observable state. + +## Decision + +PR and PR-review events project the current PR phase to every exact same-repository resolving Issue. A draft PR, or a non-draft PR without a review request or submitted review, targets `In progress`. A non-draft PR with either form of review activity targets `In review`. + +The active statuses have the order `Inbox`, `Backlog`, `Ready`, `In progress`, and `In review`. Projection writes only when the target is later in that order. It does not move an Issue backward, alter `Done` or `No action`, or add an Issue that has no Project status. The lifecycle path is independent of PR metadata validation; the separate required PR policy check continues to enforce labels, references, and priority consistency. + +This projection is intentionally one-way. It does not query from an Issue to related PRs, and it does not add a scheduled reconciler. PR events are the source of lifecycle advancement. The pure transition decision is exercised by the Issue-management test and that test runs in the `check-all`, `ci-primary`, and `ci-static` gates. + +## Verification + +`.github/issue-management/policy.test.mjs` covers advancement from every earlier active status, the draft and review distinctions, metadata-policy independence, and protection against backward or terminal transitions. `scripts/run-gates.ts` owns execution of that focused policy test in top-level local and CI gate modes. + +## Alternatives considered + +**Require `Ready` as the only source status.** This preserves a manual prerequisite but leaves stale `Inbox` and `Backlog` items even though the resolving PR proves implementation has begun. + +**Add bidirectional or scheduled reconciliation.** Looking up PRs from Issue events or sweeping the Project could repair more histories, but it adds another authority direction and recurring API work beyond the required PR-driven lifecycle. + +**Gate projection on complete PR metadata.** Labels, references, and priority still require enforcement, but a metadata defect does not make the implementation or review phase untrue. + +**Move statuses backward when a PR becomes a draft or loses reviewers.** That would make transient PR state overwrite a later observed work phase and complicate status ownership. Projection therefore remains monotonic. + +## Consequences + +- A PR event self-corrects a resolving Issue left in `Inbox`, `Backlog`, or `Ready`. +- An Issue created after the last relevant PR event waits for a later PR event or a manual status update because there is no reverse lookup or scheduled sweep. +- A draft PR remains `In progress` even if it has historical review activity; only a non-draft PR targets `In review`. +- Terminal statuses and later active statuses remain protected from regression. +- PR metadata failures remain visible through the required policy check without suppressing lifecycle projection. diff --git a/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.zh.md b/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.zh.md new file mode 100644 index 0000000000..f19cceafbd --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.zh.md @@ -0,0 +1,39 @@ +# Agent Note: PR 到 Issue 的状态仅向前投射 + +Status: implemented + +[English](2026-08-04-forward-only-pr-issue-status.md) | 中文 + +## 问题 + +Issue Project 状态表示工作所处阶段,同仓库内精确匹配的解决型关键字引用则建立权威的 PR(Pull Request)到 Issue 关系。若仅允许已处于 `Ready` 的 Issue 推进生命周期,即使实现已经明确开始,处于 `Inbox` 或 `Backlog` 的 Issue 仍会停留在原状态。只有 PR 元数据在其他方面均有效时才投射工作阶段,也会把政策合规性与可观察到的工作状态混为一谈。 + +## 决策 + +PR 事件和 PR 评审事件会把当前 PR 阶段投射到同仓库内被精确引用的每个解决型 Issue。草稿 PR,或既没有评审请求也没有已提交评审的非草稿 PR,目标状态为 `In progress`。具备上述任一类评审活动的非草稿 PR,目标状态为 `In review`。 + +活跃状态依次为 `Inbox`、`Backlog`、`Ready`、`In progress` 和 `In review`。只有目标状态在该顺序中位于当前状态之后时,投射才会写入。投射不会把 Issue 状态向后移动,不会改动 `Done` 或 `No action`,也不会把没有 Project 状态的 Issue 加入 Project。生命周期路径独立于 PR 元数据校验;另行执行的必需 PR 政策检查继续强制落实标签、引用和优先级一致性。 + +这项投射刻意保持单向。它不会从 Issue 反查关联 PR,也不会添加定时对账任务。PR 事件是推进生命周期的来源。Issue 管理测试会验证纯函数实现的状态转换决策,并且该测试会在 `check-all`、`ci-primary` 和 `ci-static` 门禁中运行。 + +## 验证 + +`.github/issue-management/policy.test.mjs` 覆盖从所有更早活跃状态推进、区分草稿与评审状态、独立于元数据政策,以及防止状态倒退或改动终态。`scripts/run-gates.ts` 负责在顶层本地门禁模式和 CI 门禁模式中执行这项专项政策测试。 + +## 考虑过的替代方案 + +**仅允许从 `Ready` 状态推进。** 这种方案保留了人工前置条件,但解决型 PR 已经证明实现开始后,仍会让处于 `Inbox` 和 `Backlog` 的条目保持陈旧状态。 + +**增加双向或定时对账。** 由 Issue 事件反查 PR,或定期扫描 Project,可以修复更多历史遗留状态;但这会新增一条反向的权威状态更新路径,并增加周期性 API 工作量,超出所需的 PR 驱动生命周期范围。 + +**以完整的 PR 元数据作为投射前提。** 标签、引用和优先级仍须强制落实,但元数据缺陷并不能否定工作实际处于实现或评审阶段。 + +**PR 转为草稿或失去评审人时将状态向后移动。** 这会让临时的 PR 状态覆盖已经观察到的更靠后工作阶段,也会使状态所有权更复杂。因此,投射保持单调。 + +## 后果 + +- PR 事件会自动纠正停留在 `Inbox`、`Backlog` 或 `Ready` 的解决型 Issue。 +- 若 Issue 创建于最后一个相关 PR 事件之后,则必须等待后续 PR 事件或人工更新状态,因为系统不会反向查找或定时扫描。 +- 即使存在历史评审活动,草稿 PR 仍保持 `In progress`;只有非草稿 PR 才会以 `In review` 为目标状态。 +- 终态以及顺序中更靠后的活跃状态不会倒退。 +- 必需的政策检查仍会暴露 PR 元数据错误,而不会因此阻止生命周期投射。 diff --git a/.github/issue-management/policy.mjs b/.github/issue-management/policy.mjs index 4c9242bab5..73703bd199 100644 --- a/.github/issue-management/policy.mjs +++ b/.github/issue-management/policy.mjs @@ -12,6 +12,7 @@ const AUDIT_MARKER = '<!-- dsh-issue-policy -->' const OWNER_LINE = /^Owner: @([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?)$/ const TYPES = new Set(['Idea', 'Feature', 'Bug', 'Research', 'Task']) const PRIORITIES = ['p0', 'p1', 'p2', 'p3'] +const ACTIVE_STATUS_ORDER = ['Inbox', 'Backlog', 'Ready', 'In progress', 'In review'] /** * Return Markdown outside balanced details elements. @@ -129,6 +130,22 @@ export function requiresPullRequestPolicy({ return !isDraft && !automated && (reviewRequestCount > 0 || reviewCount > 0) } +/** + * Derive a forward-only Issue status from the current PR phase. + * @param {string|null} currentStatus Current Project status. + * @param {{isDraft: boolean, reviewRequestCount: number, reviewCount: number}} pull PR phase. + * @returns {string|null} Status to write, or null when no forward transition exists. + */ +export function nextResolvingIssueStatus(currentStatus, pull) { + const target = + !pull.isDraft && (pull.reviewRequestCount > 0 || pull.reviewCount > 0) + ? 'In review' + : 'In progress' + const currentIndex = ACTIVE_STATUS_ORDER.indexOf(currentStatus) + const targetIndex = ACTIVE_STATUS_ORDER.indexOf(target) + return currentIndex >= 0 && currentIndex < targetIndex ? target : null +} + function stripIgnoredMarkdown(body) { const lines = body.replace(/<!--[\s\S]*?-->/g, '').split(/\r?\n/) const kept = [] @@ -491,11 +508,13 @@ async function pullRequestSnapshot(number) { } } -async function moveResolvingIssues(pull, from, to) { +async function advanceResolvingIssues(pull) { for (const number of pull.references.resolving) { const current = await issueSnapshot(number) - if (!current || current.status !== from) continue - await setStatus(number, to) + if (!current) continue + const target = nextResolvingIssueStatus(current.status, pull) + if (!target) continue + await setStatus(number, target) await auditIssue(number) } } @@ -530,12 +549,7 @@ async function runLifecycle(eventName, event) { if (eventName === 'pull_request' || eventName === 'pull_request_review') { const pull = await pullRequestSnapshot(event.pull_request.number) - const errors = validatePullRequest(pull) - if (errors.length > 0) return - await moveResolvingIssues(pull, 'Ready', 'In progress') - if (pull.reviewRequestCount > 0 || pull.reviewCount > 0) { - await moveResolvingIssues(pull, 'In progress', 'In review') - } + await advanceResolvingIssues(pull) } } diff --git a/.github/issue-management/policy.test.mjs b/.github/issue-management/policy.test.mjs index 8e0c253796..86750127a7 100644 --- a/.github/issue-management/policy.test.mjs +++ b/.github/issue-management/policy.test.mjs @@ -3,6 +3,7 @@ import test from 'node:test' import { countVisibleUnits, + nextResolvingIssueStatus, parseReferences, retainIssueReferences, requiresPullRequestPolicy, @@ -191,6 +192,49 @@ test('requires policy only after a human PR enters review', () => { ) }) +test('advances resolving Issues to the live PR phase', () => { + const draft = { isDraft: true, reviewRequestCount: 1, reviewCount: 4 } + const open = { isDraft: false, reviewRequestCount: 0, reviewCount: 0 } + const requestedReview = { isDraft: false, reviewRequestCount: 1, reviewCount: 0 } + const submittedReview = { isDraft: false, reviewRequestCount: 0, reviewCount: 1 } + + for (const status of ['Inbox', 'Backlog', 'Ready']) { + assert.equal(nextResolvingIssueStatus(status, draft), 'In progress') + assert.equal(nextResolvingIssueStatus(status, open), 'In progress') + assert.equal(nextResolvingIssueStatus(status, requestedReview), 'In review') + assert.equal(nextResolvingIssueStatus(status, submittedReview), 'In review') + } + assert.equal(nextResolvingIssueStatus('In progress', requestedReview), 'In review') + assert.equal(nextResolvingIssueStatus('In progress', submittedReview), 'In review') +}) + +test('never regresses or reopens a resolving Issue', () => { + const implementation = { isDraft: false, reviewRequestCount: 0, reviewCount: 0 } + const review = { isDraft: false, reviewRequestCount: 0, reviewCount: 1 } + + assert.equal(nextResolvingIssueStatus('In progress', implementation), null) + assert.equal(nextResolvingIssueStatus('In review', implementation), null) + assert.equal(nextResolvingIssueStatus('In review', review), null) + assert.equal(nextResolvingIssueStatus('Done', review), null) + assert.equal(nextResolvingIssueStatus('No action', review), null) + assert.equal(nextResolvingIssueStatus(null, review), null) +}) + +test('keeps lifecycle projection independent of PR metadata enforcement', () => { + const pull = { + isDraft: false, + authorType: 'User', + reviewRequestCount: 1, + reviewCount: 0, + labels: [], + references: { all: [2], resolving: [2], related: [] }, + issues: new Map([[2, { priority: null }]]), + } + + assert.ok(validatePullRequest(pull).length > 0) + assert.equal(nextResolvingIssueStatus('Inbox', pull), 'In review') +}) + test('exempts Draft, Bot, and App PRs', () => { const invalid = { isDraft: false, diff --git a/package.json b/package.json index fef0a1eb53..fd7f5447ff 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "test": "vitest run", "test:coverage": "vitest run --coverage", "test:e2e": "vitest run --config vitest.e2e.config.ts", + "test:issue-management": "node --test .github/issue-management/policy.test.mjs", "test:snapshot": "vitest run --config vitest.snapshot.config.ts", "test:snapshot:record": "DSH_SNAPSHOT=record vitest run --config vitest.snapshot.config.ts --update", "test:snapshot:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.snapshot.config.ts", diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 74d90a547d..956617c024 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -224,6 +224,7 @@ export function gatesForMode(selected: Mode): Gate[] { pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }), pnpmScript('client-domain-graph', 'verify-client-domain-graph', { label: 'client domain graph' }), pnpmScript('test', 'test'), + pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }), pnpmScript('duplication', 'duplication'), snapshotGate(), pnpmScript('build', 'build'), @@ -246,6 +247,7 @@ function ciPrimaryGates(): Gate[] { pnpmScript('constraints', 'constraints'), pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }), pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }), + pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }), pnpmScript('typecheck', 'typecheck'), lintGate(), pnpmScript('duplication', 'duplication'), @@ -343,6 +345,7 @@ function ciStaticGates(options: { ownsBuild: boolean }): Gate[] { pnpmScript('constraints', 'constraints'), pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }), pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }), + pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }), ...options.ownsBuild ? [pnpmScript('build', 'build')] : [], ...docSyncLeafGates({ includeDocTypecheck: options.ownsBuild, From 7aa0ae34b372bb5b91571830c416c661da1ae33f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:12:55 +0800 Subject: [PATCH 011/104] fix: harden issue status projection --- .github/issue-management/policy.mjs | 23 ++++++++++++++++------- scripts/run-gates.ts | 14 ++++++++------ 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/.github/issue-management/policy.mjs b/.github/issue-management/policy.mjs index 73703bd199..608291c4f8 100644 --- a/.github/issue-management/policy.mjs +++ b/.github/issue-management/policy.mjs @@ -12,7 +12,12 @@ const AUDIT_MARKER = '<!-- dsh-issue-policy -->' const OWNER_LINE = /^Owner: @([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?)$/ const TYPES = new Set(['Idea', 'Feature', 'Bug', 'Research', 'Task']) const PRIORITIES = ['p0', 'p1', 'p2', 'p3'] -const ACTIVE_STATUS_ORDER = ['Inbox', 'Backlog', 'Ready', 'In progress', 'In review'] +const TERMINAL_STATUSES = new Set(['Done', 'No action']) +const ACTIVE_STATUS_ORDER = config.statuses.filter((status) => !TERMINAL_STATUSES.has(status)) + +for (const status of ['In progress', 'In review']) { + if (!ACTIVE_STATUS_ORDER.includes(status)) throw new Error(`config.statuses 缺少 ${status}`) +} /** * Return Markdown outside balanced details elements. @@ -418,8 +423,7 @@ async function ensureProjectItem(number) { } } -async function setStatus(number, status) { - const context = await ensureProjectItem(number) +async function updateStatus(context, status) { const option = context.statusField.options.find((candidate) => candidate.name === status) if (!option) throw new Error(`Status 不存在:${status}`) if (context.item.fieldValueByName?.name === status) return @@ -441,6 +445,10 @@ async function setStatus(number, status) { ) } +async function setStatus(number, status) { + await updateStatus(await ensureProjectItem(number), status) +} + async function upsertAudit(number, errors) { const comments = await api( `/repos/${config.organization}/${config.repository}/issues/${number}/comments?per_page=100`, @@ -510,11 +518,12 @@ async function pullRequestSnapshot(number) { async function advanceResolvingIssues(pull) { for (const number of pull.references.resolving) { - const current = await issueSnapshot(number) - if (!current) continue - const target = nextResolvingIssueStatus(current.status, pull) + const context = await projectContext(number) + const target = nextResolvingIssueStatus(context.item?.fieldValueByName?.name ?? null, pull) if (!target) continue - await setStatus(number, target) + // TODO: Replace this latest-state guard with per-Issue serialization or a + // conditional ProjectV2 update; GraphQL currently has no compare-and-swap. + await updateStatus(context, target) await auditIssue(number) } } diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 956617c024..7503c77072 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -241,13 +241,19 @@ export function gatesForMode(selected: Mode): Gate[] { } } -function ciPrimaryGates(): Gate[] { +function ciSharedStaticGates(): Gate[] { return [ pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }), pnpmScript('constraints', 'constraints'), pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }), pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }), pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }), + ] +} + +function ciPrimaryGates(): Gate[] { + return [ + ...ciSharedStaticGates(), pnpmScript('typecheck', 'typecheck'), lintGate(), pnpmScript('duplication', 'duplication'), @@ -341,11 +347,7 @@ function runningNodeMajor(): number { function ciStaticGates(options: { ownsBuild: boolean }): Gate[] { return [ - pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }), - pnpmScript('constraints', 'constraints'), - pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }), - pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }), - pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }), + ...ciSharedStaticGates(), ...options.ownsBuild ? [pnpmScript('build', 'build')] : [], ...docSyncLeafGates({ includeDocTypecheck: options.ownsBuild, From c836fcd416ddf0bc0c384fa24d6abbebdeb12c8d Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Wed, 5 Aug 2026 12:43:35 +0800 Subject: [PATCH 012/104] feat(telemetry): add feedback-gated OTEL modes --- ...3-session-telemetry-otel-revival.i18n.yaml | 4 +- ...26-07-23-session-telemetry-otel-revival.md | 4 +- ...07-23-session-telemetry-otel-revival.zh.md | 4 +- .../2026-07-28-feedback-command.i18n.yaml | 4 +- .../feature/2026-07-28-feedback-command.md | 8 +- .../feature/2026-07-28-feedback-command.zh.md | 8 +- ...feedback-gated-session-telemetry.i18n.yaml | 6 + ...-08-05-feedback-gated-session-telemetry.md | 35 ++++ ...-05-feedback-gated-session-telemetry.zh.md | 35 ++++ docs/config-catalog.md | 16 +- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 4 +- .../tests/fixtures/telemetry-otel-driver.ts | 10 ++ .../tests/fixtures/telemetry-otel.cordis.yml | 9 + examples/package.json | 2 + packages/feedback/README.i18n.yaml | 4 +- packages/feedback/README.md | 2 +- packages/feedback/README.zh.md | 2 +- .../command-feedback/README.i18n.yaml | 4 +- packages/feedback/command-feedback/README.md | 4 +- .../feedback/command-feedback/README.zh.md | 4 +- packages/telemetry/README.i18n.yaml | 4 +- packages/telemetry/README.md | 6 +- packages/telemetry/README.zh.md | 6 +- .../session-telemetry-otel/README.i18n.yaml | 4 +- .../session-telemetry-otel/README.md | 16 +- .../session-telemetry-otel/README.zh.md | 16 +- .../session-telemetry-otel/package.json | 2 + .../session-telemetry-otel/src/index.ts | 94 +++++++---- .../session-telemetry-otel/src/invariant.ts | 7 +- .../tests/loader-composition.e2e.ts | 93 ++++++++--- .../session-telemetry-otel/tests/otel.spec.ts | 90 +++++++++- .../session-telemetry-otel/tsconfig.json | 3 + .../session-telemetry/README.i18n.yaml | 4 +- .../telemetry/session-telemetry/README.md | 11 +- .../telemetry/session-telemetry/README.zh.md | 11 +- .../session-telemetry/src/coordinator.ts | 158 ++++++++++++------ .../telemetry/session-telemetry/src/index.ts | 17 +- .../session-telemetry/tests/telemetry.spec.ts | 93 ++++++++++- pnpm-lock.yaml | 9 + 41 files changed, 635 insertions(+), 182 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md create mode 100644 .agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml index cd9e4f7e9f..3f487762d6 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md -2026-07-23-session-telemetry-otel-revival.md: a58598d8a956d47cb0cf6aa3e659f38314bc4b17 -2026-07-23-session-telemetry-otel-revival.zh.md: cc09717e349d5ae2ab5157bf46de30b1823c775f +2026-07-23-session-telemetry-otel-revival.md: dcbff9757cbb730b66f456535fbd7ae471b6ffd1 +2026-07-23-session-telemetry-otel-revival.zh.md: c3a098041795fa92bb4e0dd421ca09be94907cb8 diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md index a58598d8a9..dcbff9757c 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md @@ -14,7 +14,7 @@ Every deployment that wants harness sessions in an observability stack must hand - **`@deepseek-ai/dsh-session-telemetry`** — the seam. `TelemetryBackend` (`emit`/`flush?`/`shutdown`), the service-registered `Telemetry` form, and `TelemetryCoordinator` owning capture: adoption with cursor read-back, the per-append firehose (project → `structuredClone` → redact → `emit`, zero I/O), the fixed first-chunk-per-(turn, step) projection, the `agent/error` relay, and dispose-time `shutdown` records. - **The `telemetry/record` waterfall** — the delta over the branch version and the seam's redaction extension point. Every record passes it before reaching any backend; the seam ships NO rules of its own — the innermost `next()` is a pass-through, deployments mount their rules as listeners (stacking by transforming `next()`'s return value), and a throwing rule withholds the record fail-closed. Redaction applies to the exported copy only; the canonical log is never rewritten. -- **`@deepseek-ai/dsh-session-telemetry-otel`** — the reference backend: OTel JS SDK log pipeline (`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter), configured verbatim through `exporter`/`processor` passthroughs. `exporter.url` is required and validated at load; unmounted or unconfigured, nothing leaves the process. +- **`@deepseek-ai/dsh-session-telemetry-otel`** — the reference backend: OTel JS SDK log pipeline (`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter), configured verbatim through `exporter`/`processor` passthroughs. Its default `FULL` mode requires `exporter.url`; the later [feedback-gated telemetry decision](2026-08-05-feedback-gated-session-telemetry.md) adds `FEEDBACK_ONLY` and `DISABLED` delivery modes without moving the redaction or backend boundary. The boundary axiom holds: the harness's aspect ends at `emit()`. Batching, retry, queueing, and loss policy are the reporting SDK's, configured through passthroughs — delivery is best-effort (at-most-once across a crash), which the READMEs state plainly. @@ -34,4 +34,4 @@ The boundary axiom holds: the harness's aspect ends at `emit()`. Batching, retry ## Consequences -A deployment adds one `cordis.yml` entry with an OTLP endpoint and gets its session stream in any OTel-compatible stack; removing the entry is the opt-out, with no residual state. A rule-free deployment exports records exactly as captured — including any credentials embedded in file contents or command output — so a deployment crossing a trust boundary must mount `telemetry/record` listeners, and both READMEs state this plainly. Where rules are mounted, exported bodies can differ from canonical log bytes, so receivers must not treat telemetry as a byte-exact replica; the log remains the source of truth. Crash durability is explicitly out of scope until the outbox decision above is revisited. +A deployment adds one `cordis.yml` entry with an OTLP endpoint and gets its session stream in any OTel-compatible stack. `FULL` preserves that behavior by default, `FEEDBACK_ONLY` withholds records until feedback releases a prefix, and `DISABLED` constructs no reporting pipeline; removing the entry remains a silent opt-out, while the disabled mode keeps the local feedback warning. A rule-free deployment exports records exactly as captured — including any credentials embedded in file contents or command output — so a deployment crossing a trust boundary must mount `telemetry/record` listeners, and both READMEs state this plainly. Where rules are mounted, exported bodies can differ from canonical log bytes, so receivers must not treat telemetry as a byte-exact replica; the log remains the source of truth. Crash durability is explicitly out of scope until the outbox decision above is revisited. diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md index cc09717e34..c3a0980417 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md @@ -14,7 +14,7 @@ Status: implemented - **`@deepseek-ai/dsh-session-telemetry`** —— seam 本体。`TelemetryBackend`(`emit`/`flush?`/`shutdown`)、服务注册形态的 `Telemetry`、以及拥有捕获侧的 `TelemetryCoordinator`:带游标回读的收养、逐 append 的 firehose(投影 → `structuredClone` → 脱敏 → `emit`,零 I/O)、固定的每 (turn, step) 首 chunk 投影、`agent/error` 转发、以及 dispose 时的 `shutdown` 记录。 - **`telemetry/record` waterfall** —— 相对分支版本的增量,也是该 seam 的脱敏扩展点。每条记录抵达任何 backend 前必经此处;seam 自身不带任何规则——最内层 `next()` 原样透传,部署方以监听器挂载自己的规则(通过变换 `next()` 的返回值堆叠),抛异常的规则将该记录 fail-closed 扣下。脱敏只作用于导出副本;canonical log 永不改写。 -- **`@deepseek-ai/dsh-session-telemetry-otel`** —— 参考 backend:OTel JS SDK 日志管线(`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter),经 `exporter`/`processor` passthrough 原样配置。`exporter.url` 必填且加载时校验;未挂载或未配置时,任何数据都不会离开进程。 +- **`@deepseek-ai/dsh-session-telemetry-otel`** —— 参考 backend:OTel JS SDK 日志管线(`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter),经 `exporter`/`processor` passthrough 原样配置。其默认 `FULL` 模式要求 `exporter.url`;后续的[反馈门控遥测决策](2026-08-05-feedback-gated-session-telemetry.md)增加了 `FEEDBACK_ONLY` 与 `DISABLED` 投递模式,但未移动脱敏或后端边界。 边界公理保持不变:harness 的职责止于 `emit()`。批处理、重试、排队与丢失策略属于 reporting SDK,经 passthrough 配置——投递是尽力而为(崩溃时至多一次),README 对此如实陈述。 @@ -34,4 +34,4 @@ Status: implemented ## Consequences -部署方在 `cordis.yml` 加一个带 OTLP endpoint 的条目即可把会话流接入任何 OTel 兼容体系;删除条目即退出,无残留状态。未挂载规则的部署导出的记录与捕获时完全一致——包括文件内容与命令输出中内嵌的任何凭据——因此跨信任边界的部署必须挂载 `telemetry/record` 监听器,两个 README 对此如实陈述。挂载规则后,导出的 body 可能与 canonical log 字节不同,接收端不得把遥测当作字节精确副本;日志仍是唯一事实源。崩溃持久性在上述 outbox 决定重启前明确不在范围内。 +部署方在 `cordis.yml` 加一个带 OTLP endpoint 的条目即可把会话流接入任何 OTel 兼容体系。`FULL` 默认保留该行为,`FEEDBACK_ONLY` 在反馈释放前暂存记录前缀,`DISABLED` 则不构造上报流水线;删除条目仍是静默退出方式,而禁用模式会保留本地反馈警告。未挂载规则的部署导出的记录与捕获时完全一致,包括文件内容与命令输出中内嵌的任何凭据。因此,跨信任边界的部署必须挂载 `telemetry/record` 监听器,两个 README 对此如实陈述。挂载规则后,导出的 body 可能与 canonical log 字节不同,接收端不得把遥测当作字节精确副本;日志仍是真源。崩溃持久性在上述 outbox 决定重启前明确不在范围内。 diff --git a/.agents/notes/implemented/feature/2026-07-28-feedback-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-feedback-command.i18n.yaml index 7a429953d8..be039deb2c 100644 --- a/.agents/notes/implemented/feature/2026-07-28-feedback-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-feedback-command.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-feedback-command.md -2026-07-28-feedback-command.md: 1c093d0e37eb72dc66e3c5569bd642557dde56a1 -2026-07-28-feedback-command.zh.md: 300946a71ac7485a4bc787dd70ae5357147627f3 +2026-07-28-feedback-command.md: 963153ceb4332b74693ff5c1d248c616ff4e8de9 +2026-07-28-feedback-command.zh.md: 4dd02dcfb8d0606436c22e269db8c0d6cf163cee diff --git a/.agents/notes/implemented/feature/2026-07-28-feedback-command.md b/.agents/notes/implemented/feature/2026-07-28-feedback-command.md index 1c093d0e37..963153ceb4 100644 --- a/.agents/notes/implemented/feature/2026-07-28-feedback-command.md +++ b/.agents/notes/implemented/feature/2026-07-28-feedback-command.md @@ -18,7 +18,7 @@ The package declares the log-only `feedback/record { text }` session event and e `dsh-commands` still writes its `command/run` / `command/done` lifecycle pair around `/feedback`, but this command sets `recordInput: false`. Its `command/run` therefore carries the command identity and source without `args`; the feedback text exists only in `feedback/record`, while `command/done` carries the acknowledgement outcome. All three records are log-only and non-surface. Their appends start persistence's ordinary eager drain; nothing forces a flush, so acknowledgement reports that the feedback is in the log rather than already on disk. -Capture is deliberately inert: nothing in this repository reads `feedback/record`. +Capture remains inert for the running agent and model. The optional OTel telemetry package later adds one infrastructure consumer: it observes `feedback/record` as a release trigger in `FEEDBACK_ONLY` mode and as the local-only warning trigger in `DISABLED` mode, without changing the feedback event or command path. See [Feedback-gated session telemetry](2026-08-05-feedback-gated-session-telemetry.md). ### Why feedback owns an event @@ -34,7 +34,7 @@ Surrounding whitespace is discarded, but nothing else is parsed. `/feedback /pla ### A new group -`packages/feedback/` is a new group because no existing one owns this. `goal/` is objective state, `session-title/` is titles, `core/` is the product spine. The group holds one package; a consumer would join it rather than forcing this one to grow. +`packages/feedback/` is a new group because no existing one owns this. `goal/` is objective state, `session-title/` is titles, `core/` is the product spine. The group holds one producer package; cross-cutting consumers stay in their owning groups rather than forcing this one to grow. ## Alternatives considered @@ -48,7 +48,7 @@ Surrounding whitespace is discarded, but nothing else is parsed. `/feedback /pla **Register the command inside an existing package** such as `packages/ui/commands`. Avoids a new group and its README pair. Rejected: `ctx.commands` is the registry, not a home for arbitrary command implementations, and the requester asked for a standalone package. -**Parse structure out of the text** (category prefixes, severity markers). Rejected as speculative: no consumer exists to use the structure, and any control-word grammar makes the corresponding literal feedback unrecordable. Verbatim text is the widest surface a future consumer can narrow; a parsed one cannot be widened after the fact. +**Parse structure out of the text** (category prefixes, severity markers). Rejected as speculative: no consumer needs that structure, and any control-word grammar makes the corresponding literal feedback unrecordable. Verbatim text is the widest surface a future consumer can narrow; a parsed one cannot be widened after the fact. **Add a model-facing tool instead of a slash command.** Rejected: feedback is a direct human observation. Routing it through the model spends a turn, lets the model paraphrase the user's words, and makes the record contingent on the model choosing to call the tool. @@ -58,6 +58,6 @@ The TUI mounts the command unconditionally — no configuration, no dependency o The package owns one independent append-only event with no cross-event or mutable-data relation for an invariant companion to check. The event follows the session log's existing replay, fork, persistence, and crash-tail behavior. -Deferred: no consumer; no structured fields; no amend or withdraw, since the log is append-only and this package adds no tombstone; and no explicit durability barrier, so an entry recorded immediately before a crash can be lost with any other unflushed tail. +Deferred: no product or model consumer; no structured fields; no amend or withdraw, since the log is append-only and this package adds no tombstone; and no explicit durability barrier, so an entry recorded immediately before a crash can be lost with any other unflushed tail. The optional telemetry consumer treats the event only as an export-policy trigger. No snapshot accompanies this change. AGENTS.md asks for a keyless snapshot through a runnable example for product-user-visible behavior; this was skipped at the requester's explicit direction. The package tests plus a real Loader composition test over a `cordis.yml` are the whole of the evidence, alongside interactive verification in the assembled TUI. diff --git a/.agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md b/.agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md index 300946a71a..4dd02dcfb8 100644 --- a/.agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md @@ -18,7 +18,7 @@ Status: implemented `dsh-commands` 仍会围绕 `/feedback` 写入 `command/run` / `command/done` 生命周期配对,但该命令设置了 `recordInput: false`。因此,它的 `command/run` 携带命令标识与来源,但不携带 `args`;反馈文本只存在于 `feedback/record` 中,而 `command/done` 携带确认结果。三个记录都仅写入日志且非 surface。它们的追加会启动持久化的常规即时排空;没有任何环节强制 flush,因此确认文本报告的是反馈已进入日志,而非已经落盘。 -采集刻意不产生后续动作:本仓库中没有任何代码读取 `feedback/record`。 +采集对正在运行的 agent 与模型仍不产生后续动作。可选的 OTel 遥测包后续增加了一个基础设施消费方:它在 `FEEDBACK_ONLY` 模式下将 `feedback/record` 作为释放触发器,在 `DISABLED` 模式下将其作为本地警告触发器,且不改变反馈事件或命令路径。见[反馈门控的会话遥测](2026-08-05-feedback-gated-session-telemetry.md)。 ### 为何反馈拥有自己的事件 @@ -34,7 +34,7 @@ Status: implemented ### 一个新的分组 -`packages/feedback/` 是新分组,因为现有分组都不拥有此职责:`goal/` 负责目标状态,`session-title/` 负责标题,`core/` 是产品主干。该分组目前只有一个包;未来的消费方应加入该分组,而不是迫使这个包不断膨胀。 +`packages/feedback/` 是新分组,因为现有分组都不拥有此职责:`goal/` 负责目标状态,`session-title/` 负责标题,`core/` 是产品主干。该分组只包含一个生产方包;跨领域的消费方留在各自所属的分组,而不是迫使这个包不断膨胀。 ## 考虑过的替代方案 @@ -48,7 +48,7 @@ Status: implemented **在现有包中注册该命令**,例如 `packages/ui/commands`。可省去新分组及其双语 README。已否决:`ctx.commands` 是注册表,而不是任意命令实现的归属地;且请求者明确要求独立的包。 -**从文本中解析结构**(类别前缀、严重程度标记)。已否决,属于投机设计:目前没有消费方使用该结构,而任何控制词语法都会让对应的字面反馈无法记录。原样文本是未来消费方可以收窄的最宽接口;而已被解析的接口无法事后放宽。 +**从文本中解析结构**(类别前缀、严重程度标记)。已否决,属于投机设计:没有消费方需要该结构,而任何控制词语法都会让对应的字面反馈无法记录。原样文本是未来消费方可以收窄的最宽接口;而已被解析的接口无法事后放宽。 **改为提供面向模型的工具。** 已否决:反馈是人类的直接观察。经由模型会消耗一个轮次、让模型改写用户的原话,并使记录取决于模型是否选择调用该工具。 @@ -58,6 +58,6 @@ TUI 无条件挂载该命令:没有配置,也不依赖 goal 栈。无头 CLI 本包拥有一个独立的仅追加事件,不存在跨事件关系或可变数据关系可供不变式伴生插件检查。该事件遵循会话日志现有的回放、fork、持久化和崩溃尾部行为。 -延期事项:没有消费方;没有结构化字段;不支持修改或撤回,因为日志仅追加且本包不新增 tombstone;且没有显式持久化屏障,因此紧临崩溃前记录的条目可能与其他未 flush 的尾部一同丢失。 +延期事项:没有产品或模型消费方;没有结构化字段;不支持修改或撤回,因为日志仅追加且本包不新增 tombstone;且没有显式持久化屏障,因此紧临崩溃前记录的条目可能与其他未 flush 的尾部一同丢失。可选的遥测消费方只将该事件作为导出策略触发器。 本次变更不附带 snapshot。AGENTS.md 要求面向产品用户的可见行为变更通过可运行示例附带无密钥 snapshot;此项按请求者的明确指示跳过。包测试连同一个基于真实 `cordis.yml` 的 Loader 组合测试即为全部证据,此外还有在组装后 TUI 中的交互验证。 diff --git a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml new file mode 100644 index 0000000000..d12ad78728 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.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-05-feedback-gated-session-telemetry.md +2026-08-05-feedback-gated-session-telemetry.md: 21a9028c603f3faaec39b2ddb8ef14644d6c84d4 +2026-08-05-feedback-gated-session-telemetry.zh.md: ea94c743b962a93a5fc64bdc2e4ed103aadecc99 diff --git a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md new file mode 100644 index 0000000000..21a9028c60 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md @@ -0,0 +1,35 @@ +# Agent Note: Feedback-gated session telemetry + +Status: implemented + +English | [中文](2026-08-05-feedback-gated-session-telemetry.zh.md) + +## Problem + +Session telemetry originally has one mounted behavior: every accepted record enters the reporting backend immediately. Deployments need two stricter policies without replacing the plugin: hold a session's telemetry unless its user records feedback, or disable reporting while still explaining what happens to feedback. The policy must preserve the existing full-export default and the telemetry seam's redaction-before-backend boundary. + +## Decision + +`@deepseek-ai/dsh-session-telemetry-otel` exposes three uppercase `mode` values: + +- `FULL` is the default and preserves immediate delivery to the configured OTel pipeline. +- `FEEDBACK_ONLY` captures redacted copies in memory and releases the pending session prefix when `feedback/record` is appended. The released prefix includes the feedback event itself. Records appended after that event form another withheld prefix until another feedback event releases them. +- `DISABLED` constructs no exporter, processor, or logger provider. A `feedback/record` listener prints that nothing is shared and the feedback remains local. + +The generic telemetry coordinator owns the delivery distinction as `immediate` or `held`. Both paths project, clone, and run `telemetry/record` listeners at capture time. Immediate delivery sends the accepted record to the backend and advances the session's handoff cursor. Held delivery retains the accepted record per session without moving that cursor. `release(session)` submits the retained records in order, contains each backend failure independently, advances the cursor only for submitted records, and removes the released prefix. + +The OTel feedback listener is registered after the coordinator's session listener. Cordis therefore gives the coordinator the feedback append first, then the OTel listener releases a prefix that already contains that event. `exporter.url` is required in `FULL` and `FEEDBACK_ONLY`; `DISABLED` does not validate or use exporter configuration. + +## Alternatives considered + +**Open a session permanently after its first feedback.** Rejected because later work would be shared without another feedback act and the plugin would need additional open-session state. Releasing one pending prefix per feedback has the smaller state machine and the narrower sharing boundary. + +**Buffer after `TelemetryCoordinator.emit()` in the OTel backend.** Rejected because the coordinator would advance its handoff cursor before a record became eligible for upload. A plugin rebuild would then lose the only retained copy and incorrectly treat the prefix as handed off. + +**Replay the canonical session log when feedback arrives.** Rejected because replay would repeat projection and redaction, exclude telemetry operation records that are not session events, and require more lifecycle state to distinguish previously released prefixes. + +**Use an unmounted plugin as the disabled state.** That remains the silent opt-out, but it cannot warn when feedback is recorded. The explicit disabled mode lets a deployment keep one configuration shape and communicate that the local feedback did not leave the process. + +## Consequences + +`FULL` remains source- and wire-compatible with the original default. `FEEDBACK_ONLY` retains deep-copied, already-redacted records in process memory until feedback or session collection; a crash before release uploads nothing from that prefix. A clean shutdown after the last feedback is part of the new withheld suffix, so feedback-only streams do not carry a reliable shutdown or crash signal. Each later feedback releases the suffix accumulated since the previous one. `DISABLED` can omit `exporter.url`, does no reporting work, and keeps feedback only in the canonical session log. diff --git a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md new file mode 100644 index 0000000000..ea94c743b9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md @@ -0,0 +1,35 @@ +# Agent Note:反馈门控的会话遥测 + +Status: implemented + +[English](2026-08-05-feedback-gated-session-telemetry.md) | 中文 + +## 问题 + +会话遥测原本只有一种已挂载行为:每条已接受记录都立即进入上报后端。部署方需要两种更严格的策略,且不替换插件:只有用户记录反馈时才释放该会话的遥测,或禁用上报并仍向用户说明反馈的去向。该策略必须保留现有的全量导出默认值,以及遥测 seam 在记录抵达后端之前脱敏的边界。 + +## 决策 + +`@deepseek-ai/dsh-session-telemetry-otel` 公开三个大写的 `mode` 值: + +- `FULL` 是默认值,保留向已配置 OTel 流水线的即时投递。 +- `FEEDBACK_ONLY` 在内存中捕获已脱敏副本,并在追加 `feedback/record` 时释放待处理的会话前缀。已释放前缀包含反馈事件本身。在该事件之后追加的记录会形成另一个暂存前缀,直到下一个反馈事件将其释放。 +- `DISABLED` 不构造导出器、处理器或日志提供方。`feedback/record` 监听器会输出警告,说明什么都不会共享,且反馈仍留在本地。 + +通用遥测协调器以 `immediate` 或 `held` 的形式拥有这两种投递方式。两条路径都会在捕获时进行投影、深拷贝,并运行 `telemetry/record` 监听器。即时投递把已接受记录发送到后端,并推进会话的 handoff 游标。暂存投递按会话保留已接受记录,且不移动该游标。`release(session)` 按顺序提交保留的记录,独立隔离每个后端失败,仅为已提交的记录推进游标,并移除已释放前缀。 + +OTel 反馈监听器在协调器的会话监听器之后注册。因此,Cordis 先将反馈追加交给协调器,再由 OTel 监听器释放已包含该事件的前缀。`exporter.url` 在 `FULL` 与 `FEEDBACK_ONLY` 中必填;`DISABLED` 不校验也不使用导出器配置。 + +## 考虑过的替代方案 + +**会话在首次反馈后永久开放。** 已否决,因为后续工作会在用户未再次提交反馈的情况下被共享,而且插件需要额外的会话开放状态。每次反馈只释放一个待处理前缀,状态机更小,共享边界也更窄。 + +**在 OTel 后端的 `TelemetryCoordinator.emit()` 之后缓冲。** 已否决,因为协调器会在记录具备上传资格前推进 handoff 游标。插件重建后,唯一保留的副本会丢失,而协调器会错误地将该前缀视为已交接。 + +**反馈到达时回放权威会话日志。** 已否决,因为回放会重复执行投影与脱敏,排除不属于会话事件的遥测运维记录,且需要更多生命周期状态才能区分已释放前缀。 + +**以不挂载插件表示禁用状态。** 这仍然是静默退出方式,但无法在记录反馈时输出警告。显式禁用模式让部署方可以保持同一种配置形态,并说明本地反馈未离开进程。 + +## 后果 + +`FULL` 与原有默认值保持源码及协议兼容。`FEEDBACK_ONLY` 会在进程内存中保留已深拷贝且已脱敏的记录,直到收到反馈或会话被回收;释放前发生崩溃时,该前缀不上传任何内容。上次反馈之后的干净关闭属于新的暂存后缀,因此仅反馈的流不携带可靠的关闭或崩溃信号。每个后续反馈都会释放从上一个反馈开始累积的后缀。`DISABLED` 可省略 `exporter.url`,不执行任何上报工作,并仅在权威会话日志中保留反馈。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f552e6ab63..0c4fb632c1 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1159,12 +1159,13 @@ Requires: `sessions` ```ts config-catalog /** - * Plugin configuration: two verbatim SDK option shapes plus nothing else. - * `exporter.url` is the one field this package validates itself — required, - * no default, must parse as an `http(s)` URL — because a missing endpoint - * must fail at plugin load, not at first export. + * Plugin configuration: one sharing policy plus two verbatim SDK option + * shapes. `exporter.url` is required for modes that upload and unused for + * `DISABLED`. */ export interface Config { + /** Sharing policy; defaults to immediate `FULL` delivery. */ + mode?: TelemetryMode /** * Passed verbatim to the SDK's OTLP/HTTP log exporter — the complete * `OTLPExporterNodeConfigBase` shape (`headers`, `timeoutMillis`, @@ -1172,7 +1173,7 @@ export interface Config { * is the one field this package requires and validates itself. */ exporter?: OTLPExporterNodeConfigBase & { - /** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required; validated at plugin load. */ + /** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required outside `DISABLED`; validated at load. */ url?: string } /** @@ -1181,11 +1182,14 @@ export interface Config { */ processor?: Omit<BatchLogRecordProcessorOptions, 'exporter'> } + +/** Session-sharing policy selected by {@link Config.mode}. */ +export type TelemetryMode = typeof TELEMETRY_MODES[number] ``` Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) · `OTLPExporterNodeConfigBase` (`@opentelemetry/otlp-exporter-base`) -Source: [`packages/telemetry/session-telemetry-otel/src/index.ts:40`](../packages/telemetry/session-telemetry-otel/src/index.ts) +Source: [`packages/telemetry/session-telemetry-otel/src/index.ts:54`](../packages/telemetry/session-telemetry-otel/src/index.ts) ## `@deepseek-ai/dsh-session-title` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 54291934fd..d159fa0a53 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -853,7 +853,7 @@ Transform one outbound record before it reaches the backend. This waterfall is t 'telemetry/record'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord ``` -Source: [`packages/telemetry/session-telemetry/src/index.ts:41`](../../packages/telemetry/session-telemetry/src/index.ts) +Source: [`packages/telemetry/session-telemetry/src/index.ts:42`](../../packages/telemetry/session-telemetry/src/index.ts) ## `tools/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 395d0850e1..e051463877 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1996,7 +1996,7 @@ flush?(): void abstract shutdown(): Promise<void> ``` -Source: [`packages/telemetry/session-telemetry/src/index.ts:135`](../../packages/telemetry/session-telemetry/src/index.ts) +Source: [`packages/telemetry/session-telemetry/src/index.ts:140`](../../packages/telemetry/session-telemetry/src/index.ts) ## `ctx.tokenMeter` — `TokenMeterService` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index fabd16bbdd..4ccc19f305 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -33,7 +33,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:58`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:103`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | | `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:230`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:244`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | @@ -45,7 +45,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:131`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | -| `telemetry/record` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:41`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - | +| `telemetry/record` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:42`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:156`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | | `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:138`](../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:113`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) | diff --git a/examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts b/examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts index 02be1a9011..72305f0724 100644 --- a/examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts +++ b/examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts @@ -11,6 +11,7 @@ import { createServer } from 'node:http' import { once } from 'node:events' import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' import { runOneShot } from '@deepseek-ai/dsh-cli-demo/src/cli.ts' +import { recordFeedback } from '@deepseek-ai/dsh-command-feedback' const configPath = process.argv[2] if (configPath === undefined) throw new Error('telemetry-otel driver requires a config path') @@ -35,6 +36,15 @@ try { // The fixture credential rides the model-visible user message; the exported // copy must scrub it while the canonical log keeps the original bytes. await runOneShot(ctx, { task: 'prove telemetry with key sk-e2efixture1234567890' }) + const mode = process.env.DSH_TELEMETRY_E2E_MODE ?? 'FULL' + if (mode !== 'FULL') { + const [agent] = ctx.get('agents')?.roots() ?? [] + if (agent === undefined) throw new Error('telemetry-otel driver requires one root agent') + recordFeedback(agent.session, 'fixture feedback') + if (mode === 'FEEDBACK_ONLY') { + await runOneShot(ctx, { task: 'post-feedback private suffix' }) + } + } } finally { await ctx.fiber.dispose() } diff --git a/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml b/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml index 34e23b828e..1433173768 100644 --- a/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml +++ b/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml @@ -2,6 +2,14 @@ # path, exporting to the mock OTLP collector the driver starts (url via env). # The redact-rule entry models a deployment mounting its own scrub rule on the # telemetry/record waterfall — the seam itself ships no rules. +- id: logger-console + name: '@cordisjs/plugin-logger-console' + config: + colors: false + levels: + default: 3 + showTime: '' + - id: cli-mock-llm name: './cli-mock-llm.ts' @@ -14,6 +22,7 @@ - id: telemetry-otel name: '@deepseek-ai/dsh-session-telemetry-otel' config: + mode: !!js process.env.DSH_TELEMETRY_E2E_MODE || 'FULL' exporter: url: !!js process.env.DSH_TELEMETRY_E2E_URL diff --git a/examples/package.json b/examples/package.json index 51fc48b8fa..0298685693 100644 --- a/examples/package.json +++ b/examples/package.json @@ -7,6 +7,7 @@ "dependencies": { "@cordisjs/plugin-hmr": "workspace:*", "@cordisjs/plugin-include": "workspace:*", + "@cordisjs/plugin-logger-console": "workspace:*", "@deepseek-ai/dsh-acp-demo": "workspace:*", "@deepseek-ai/dsh-agent-spine-demo": "workspace:*", "@deepseek-ai/dsh-app-boot": "workspace:*", @@ -14,6 +15,7 @@ "@deepseek-ai/dsh-bash-sandbox": "workspace:*", "@deepseek-ai/dsh-cli-demo": "workspace:*", "@deepseek-ai/dsh-code-runtime-worker": "workspace:*", + "@deepseek-ai/dsh-command-feedback": "workspace:*", "@deepseek-ai/dsh-compact-basic": "workspace:*", "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:*", "@deepseek-ai/dsh-fs-local": "workspace:*", diff --git a/packages/feedback/README.i18n.yaml b/packages/feedback/README.i18n.yaml index 31ed2d25e8..4ad5a93fb5 100644 --- a/packages/feedback/README.i18n.yaml +++ b/packages/feedback/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/feedback/README.md -README.md: 7962a16ee9bc7d8a969a466591d761829cd55d7f -README.zh.md: aad8f4d797ff16a5ef9be4c968fb28d708bad13e +README.md: d2a4a5a27e1c661d2f62b328578fd890a0c622ee +README.zh.md: 2fa42e3bb5f05dfc425356f302f44e497b100f24 diff --git a/packages/feedback/README.md b/packages/feedback/README.md index 7962a16ee9..d2a4a5a27e 100644 --- a/packages/feedback/README.md +++ b/packages/feedback/README.md @@ -8,4 +8,4 @@ The feedback family lets a human record a remark about the session without actin |---|---|---| | `command-feedback/` | Trigger-independent `feedback/record` event plus the human-facing `/feedback` producer | — | -A recorded remark is log-only: it never enters the model surface or derived history, and no shipped plugin consumes it. A future consumer reads `feedback/record` events from the session log rather than changing how they are captured. +A recorded remark is log-only: it never enters the model surface or derived history. When mounted, [`dsh-session-telemetry-otel`](../telemetry/session-telemetry-otel/) observes `feedback/record` to release a pending telemetry prefix or warn that disabled telemetry leaves the feedback local; capture itself remains independent of that policy. diff --git a/packages/feedback/README.zh.md b/packages/feedback/README.zh.md index aad8f4d797..2fa42e3bb5 100644 --- a/packages/feedback/README.zh.md +++ b/packages/feedback/README.zh.md @@ -8,4 +8,4 @@ feedback 家族让人类记录对会话的评价,但不据此采取任何动 |---|---|---| | `command-feedback/` | 与触发方式无关的 `feedback/record` 事件,以及面向用户的 `/feedback` 生产方 | 无 | -被记录的评价仅写入日志:它绝不会进入模型 surface 或派生历史,随附插件也不会消费它。未来的消费方从会话日志中读取 `feedback/record` 事件,而不是改变它们的采集方式。 +被记录的评价仅写入日志:它绝不会进入模型 surface 或派生历史。挂载后,[`dsh-session-telemetry-otel`](../telemetry/session-telemetry-otel/) 会观察 `feedback/record`,以释放待处理的遥测前缀,或在遥测已禁用时警告反馈将留在本地;采集本身与该策略相互独立。 diff --git a/packages/feedback/command-feedback/README.i18n.yaml b/packages/feedback/command-feedback/README.i18n.yaml index 47c169ec3f..ea439ce2fe 100644 --- a/packages/feedback/command-feedback/README.i18n.yaml +++ b/packages/feedback/command-feedback/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/feedback/command-feedback/README.md -README.md: c9650d6a2c595550545b3dbf07f62e6aa65f39b9 -README.zh.md: ba24276ba1bd71a4eb68c7fdb48a3760bdbec8fc +README.md: e3b0e58f1746c7bcd1c74ac0990a872a1f24d7d7 +README.zh.md: 40ec871caff6f90b0b1c685e833c874e32a48d16 diff --git a/packages/feedback/command-feedback/README.md b/packages/feedback/command-feedback/README.md index c9650d6a2c..e3b0e58f17 100644 --- a/packages/feedback/command-feedback/README.md +++ b/packages/feedback/command-feedback/README.md @@ -15,7 +15,7 @@ Surrounding whitespace is discarded, but feedback is otherwise unparsed: no trun ## What this plugin does and does not do -`recordFeedback(session, text)` is the command-independent write path. It rejects empty normalized text and appends `feedback/record { text }`; a different UI, hook, or host integration can call it without constructing a slash command. The `/feedback` handler uses that producer, starts no model work, and no plugin in this repository reads the event. +`recordFeedback(session, text)` is the command-independent write path. It rejects empty normalized text and appends `feedback/record { text }`; a different UI, hook, or host integration can call it without constructing a slash command. The `/feedback` handler uses that producer and starts no model work. The optional [`dsh-session-telemetry-otel`](../../telemetry/session-telemetry-otel/) consumer observes the event without changing its capture contract. The feedback text appears in exactly one durable payload: `feedback/record`. [`dsh-commands`](../../ui/commands/README.md) still appends its generic `command/run` / `command/done` pairing, but this definition sets `recordInput: false`, so `command/run` omits `args`; the paired `command/done` carries only the outcome. All three events are log-only and absent from the ordered surface, `deriveMessages()`, and model requests. These appends start persistence's ordinary eager drain, but neither producer forces `session/flush`, so acknowledgement means the feedback is in the log, not that it has reached disk. Rejected empty input leaves only the command pairing settled as `kind: 'error'`, with no `feedback/record`. @@ -52,7 +52,7 @@ Independent of the model request path. Recording appends to the session log only ## Known Limitations and Deferred Work -- **Nothing consumes the recorded feedback** — capture is deliberately inert. There is no retrieval, aggregation, export, or reporting surface, and no model-facing tool reads `feedback/record`; a consumer is a separate package. +- **No feedback retrieval or management surface** — the optional OTel plugin uses the event only as a sharing trigger. There is no retrieval, aggregation, categorization, or model-facing tool for `feedback/record`. - **No structured fields** — an entry is one free-text string with no category, severity, or referenced-event link, so feedback cannot be filtered by subject without re-reading its text. - **No amend or withdraw** — the session log is append-only and this package adds no tombstone, so a mistaken entry stays recorded and can only be superseded by a later one. - **No explicit durability barrier** — the acknowledgement follows the append, not a flush, so an entry recorded immediately before a crash can be lost with any other unflushed tail. Feedback is not worth forcing a synchronous disk write for; a consumer that needs one awaits `ctx.sessions.flush(session)`. diff --git a/packages/feedback/command-feedback/README.zh.md b/packages/feedback/command-feedback/README.zh.md index ba24276ba1..40ec871caf 100644 --- a/packages/feedback/command-feedback/README.zh.md +++ b/packages/feedback/command-feedback/README.zh.md @@ -15,7 +15,7 @@ ## 本插件做什么、不做什么 -`recordFeedback(session, text)` 是不依赖命令的写入路径。它拒绝规范化后为空的文本,并追加 `feedback/record { text }`;其他 UI、钩子或 host 集成无需构造斜杠命令即可调用它。`/feedback` 处理器通过该生产方写入,不启动任何模型工作;本仓库中也没有任何插件读取该事件。 +`recordFeedback(session, text)` 是不依赖命令的写入路径。它拒绝规范化后为空的文本,并追加 `feedback/record { text }`;其他 UI、钩子或 host 集成无需构造斜杠命令即可调用它。`/feedback` 处理器通过该生产方写入,且不启动任何模型工作。可选的 [`dsh-session-telemetry-otel`](../../telemetry/session-telemetry-otel/) 消费方会观察该事件,但不改变它的采集契约。 反馈文本只出现在一个持久载荷中:`feedback/record`。[`dsh-commands`](../../ui/commands/README.md) 仍会追加通用的 `command/run` / `command/done` 配对,但此定义设置了 `recordInput: false`,因此 `command/run` 会省略 `args`;配对的 `command/done` 只携带结果。三个事件都仅写入日志,不出现在有序 surface、`deriveMessages()` 以及模型请求中。这些追加会启动持久化的常规即时排空,但两个生产方都不会强制 `session/flush`,因此确认文本表示反馈已进入日志,而不表示它已经落盘。被拒绝的空输入只会留下以 `kind: 'error'` 结算的命令配对,不会产生 `feedback/record`。 @@ -52,7 +52,7 @@ TUI 应用无条件挂载此命令;它没有配置,也不依赖持久 goal ## 已知限制与暂缓工作 -- **没有任何消费方读取被记录的反馈**:采集刻意不产生任何后续动作。这里没有检索、聚合、导出或报告 surface,也没有面向模型的工具读取 `feedback/record`;消费方是另一个独立包。 +- **没有反馈检索或管理 surface**:可选的 OTel 插件仅将该事件用作共享触发器。本包不为 `feedback/record` 提供检索、聚合、分类或面向模型的工具。 - **没有结构化字段**:一条条目就是一个自由文本字符串,没有类别、严重程度或关联事件链接,因此无法在不重读文本的情况下按主题过滤反馈。 - **不支持修改或撤回**:会话日志是仅追加的,本包也不新增 tombstone,因此错误的条目会一直保留在记录中,只能由后续条目取代。 - **没有显式持久化屏障**:确认文本紧随追加而非 flush,因此紧临崩溃前记录的条目可能与其他未 flush 的尾部一同丢失。为反馈强制同步写盘并不值得;需要该保证的消费方可自行等待 `ctx.sessions.flush(session)`。 diff --git a/packages/telemetry/README.i18n.yaml b/packages/telemetry/README.i18n.yaml index 41f1bd956f..cd3be8d155 100644 --- a/packages/telemetry/README.i18n.yaml +++ b/packages/telemetry/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/telemetry/README.md -README.md: 944cb3f9bac6169feddf8b49bc481cfbe7c6fa9d -README.zh.md: 795b20abb47e1bf791730cc7f3ebb0522549a271 +README.md: 0adf140a19bd6ab19c4d4139d4ebdae941c0d1b0 +README.zh.md: 57988732e36d105ebcc48adcdab9344a6cccb525 diff --git a/packages/telemetry/README.md b/packages/telemetry/README.md index 944cb3f9ba..0adf140a19 100644 --- a/packages/telemetry/README.md +++ b/packages/telemetry/README.md @@ -2,9 +2,9 @@ English | [中文](README.zh.md) -Outbound session reporting: the telemetry seam plus its OpenTelemetry backend. The design — the boundary axiom (the harness's aspect ends at `emit()`; delivery is the reporting SDK's), the `telemetry/record` waterfall (deployment-mounted redaction rules; the seam ships none), the fixed chunk projection, the handoff cursor, and the operational-record channel — is pinned in [the revival Agent Note](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md). +Outbound session reporting: the telemetry seam plus its OpenTelemetry backend. The boundary axiom, redaction waterfall, fixed chunk projection, handoff cursor, and operational-record channel are pinned in [the revival Agent Note](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md); immediate, feedback-gated, and disabled delivery are owned by [the mode decision](../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md). | Package | Role | |---|---| -| [`@deepseek-ai/dsh-session-telemetry`](session-telemetry/) | The seam: capture points, projection, redaction, handoff cursor, ops signals, and the minimal backend contract (`emit`/`flush?`/`shutdown`). | -| [`@deepseek-ai/dsh-session-telemetry-otel`](session-telemetry-otel/) | The backend a deployment loads: the OTel JS SDK's log pipeline (`LoggerProvider` + `BatchLogRecordProcessor` + OTLP/HTTP exporter), configured verbatim through passthroughs. | +| [`@deepseek-ai/dsh-session-telemetry`](session-telemetry/) | The seam: capture points, projection, redaction, immediate or held handoff, cursor, ops signals, and the minimal backend contract (`emit`/`flush?`/`shutdown`). | +| [`@deepseek-ai/dsh-session-telemetry-otel`](session-telemetry-otel/) | The backend a deployment loads: `FULL`, `FEEDBACK_ONLY`, or `DISABLED` policy around the OTel JS SDK log pipeline. | diff --git a/packages/telemetry/README.zh.md b/packages/telemetry/README.zh.md index 795b20abb4..57988732e3 100644 --- a/packages/telemetry/README.zh.md +++ b/packages/telemetry/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -面向外部的会话上报:遥测(telemetry)seam 及其 OpenTelemetry 后端。整套设计固定在[复活 Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)中:边界公理(harness 的职责止于 `emit()`,投递由上报 SDK 负责)、`telemetry/record` waterfall(瀑布式事件;脱敏规则由部署方挂载,seam 自身不带任何规则)、固定分片投影、handoff 游标,以及运维记录通道。 +面向外部的会话上报:遥测(telemetry)seam 及其 OpenTelemetry 后端。边界公理、脱敏 waterfall(瀑布式事件)、固定分片投影、handoff 游标及运维记录通道的决定见[复活 Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md);即时、反馈门控及禁用投递由[模式决策](../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md)统一规定。 | 包(package) | 职责 | |---|---| -| [`@deepseek-ai/dsh-session-telemetry`](session-telemetry/) | seam 本体:捕获点、投影、脱敏、handoff 游标、运维信号,以及最小后端契约(`emit`/`flush?`/`shutdown`)。 | -| [`@deepseek-ai/dsh-session-telemetry-otel`](session-telemetry-otel/) | 部署方要加载的后端:OTel JS SDK 的日志流水线(`LoggerProvider` + `BatchLogRecordProcessor` + OTLP/HTTP 导出器),经透传(passthrough)原样配置。 | +| [`@deepseek-ai/dsh-session-telemetry`](session-telemetry/) | seam 本体:捕获点、投影、脱敏、即时或暂存交接、游标、运维信号,以及最小后端契约(`emit`/`flush?`/`shutdown`)。 | +| [`@deepseek-ai/dsh-session-telemetry-otel`](session-telemetry-otel/) | 部署方要加载的后端:围绕 OTel JS SDK 日志流水线实施 `FULL`、`FEEDBACK_ONLY` 或 `DISABLED` 策略。 | diff --git a/packages/telemetry/session-telemetry-otel/README.i18n.yaml b/packages/telemetry/session-telemetry-otel/README.i18n.yaml index b1a2052a3f..6557557b8c 100644 --- a/packages/telemetry/session-telemetry-otel/README.i18n.yaml +++ b/packages/telemetry/session-telemetry-otel/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/telemetry/session-telemetry-otel/README.md -README.md: 9b208e291e77bee50d9d4fd14808268dca75f2db -README.zh.md: 76de1bf1ad58a0239907f3b63c672177874c7966 +README.md: fab2461477b2174bded42ed6f05ae55c7c5f697c +README.zh.md: ab0191188836e03434adbce527d31b62ead848a3 diff --git a/packages/telemetry/session-telemetry-otel/README.md b/packages/telemetry/session-telemetry-otel/README.md index 9b208e291e..fab2461477 100644 --- a/packages/telemetry/session-telemetry-otel/README.md +++ b/packages/telemetry/session-telemetry-otel/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — the only entry a deployment loads. It composes the OTel JS SDK as-is (`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP log exporter) and maps each record the seam hands over onto `logger.emit()`, under two instrumentation scopes: ledger records on `@deepseek-ai/dsh-session-telemetry-otel`, operational records on `@deepseek-ai/dsh-session-telemetry-otel/ops`. Resource identity (`service.name`/`service.version`) comes from `dsh-llm`'s `APP_IDENTITY`, the same source the attribution headers use. +The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — the only entry a deployment loads. Its `mode` decides whether the seam hands records over immediately, releases them only at recorded feedback, or keeps telemetry local. Uploading modes compose the OTel JS SDK as-is (`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP log exporter) and map each handed-over record onto `logger.emit()`, under two instrumentation scopes: ledger records on `@deepseek-ai/dsh-session-telemetry-otel`, operational records on `@deepseek-ai/dsh-session-telemetry-otel/ops`. Resource identity (`service.name`/`service.version`) comes from `dsh-llm`'s `APP_IDENTITY`, the same source the attribution headers use. ## Config @@ -10,6 +10,7 @@ The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — th - id: telemetry-otel name: '@deepseek-ai/dsh-session-telemetry-otel' config: + mode: FULL # FULL (default), FEEDBACK_ONLY, or DISABLED exporter: # passed verbatim to the SDK's OTLP/HTTP log exporter url: https://collector.example.com/v1/logs headers: @@ -17,15 +18,21 @@ The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — th processor: {} # optional; passed verbatim to BatchLogRecordProcessor ``` -`exporter.url` is the one field this package validates itself — required, no default, must parse as `http(s)` — so a missing endpoint fails at plugin load (as does a non-positive-integer `processor.maxExportBatchSize`, which the SDK accepts but then hangs on at shutdown). Everything else is the SDK's option shape, owned and documented by the SDK, and both blocks pass through whole: every `OTLPExporterNodeConfigBase` field (`headers`, `timeoutMillis`, `compression`, `keepAlive`, …) reaches the exporter, and batching, export cadence (`scheduledDelayMillis`), retry, queue bounds, and loss policy under sustained failure are the SDK's documented behavior, tuned through the `processor` passthrough. The backend deliberately implements no `flush()`: the batch processor is the only flusher in the process, which is what makes `shutdown()`'s drain complete. Removing this block from `cordis.yml` is the opt-out: no residual state, no `enabled` flag. +| `mode` | Behavior | +|---|---| +| `FULL` | Default. Each projected record, including lifecycle ops records, is handed to the OTel SDK immediately. | +| `FEEDBACK_ONLY` | Each `feedback/record` releases the redacted, projected session prefix through that event. Later records wait for another feedback event and remain local if none arrives. | +| `DISABLED` | No coordinator, provider, processor, or exporter is constructed. No telemetry record leaves the process. A `feedback/record` logs `session telemetry is DISABLED; nothing will be shared and this feedback remains local`; the event remains in the local session log. | + +`exporter.url` is required in `FULL` and `FEEDBACK_ONLY`, has no default, and must parse as `http(s)`; it is optional and unused in `DISABLED`. Uploading modes also reject a non-positive-integer `processor.maxExportBatchSize`, which the SDK accepts but then hangs on at shutdown. Everything else is the SDK's option shape, owned and documented by the SDK, and both blocks pass through whole: every `OTLPExporterNodeConfigBase` field (`headers`, `timeoutMillis`, `compression`, `keepAlive`, …) reaches the exporter, and batching, export cadence (`scheduledDelayMillis`), retry, queue bounds, and loss policy under sustained failure are the SDK's documented behavior, tuned through the `processor` passthrough. The backend deliberately implements no `flush()`: the batch processor is the only flusher in the process, which is what makes `shutdown()`'s drain complete. ## What leaves the machine -Records carry the complete `event.data` as the seam's `telemetry/record` waterfall returns it — user and assistant message content, tool arguments and results (command output, file contents), the full system prompt and tool schemas (`request/header`), todo text, compaction summaries, hook `stderrSummary`, and the session `cwd` (a local path). The seam ships no redaction rules: with no `telemetry/record` listener mounted, that is the raw captured copy, so a deployment exporting beyond a trusted boundary mounts its own rules (see [the seam README](../session-telemetry/README.md#the-redact-waterfall)). Provider credentials never appear regardless: adapter API keys are constructor parameters, not session events, so they are structurally absent from the log and therefore from telemetry. +In uploading modes, records carry the complete `event.data` as the seam's `telemetry/record` waterfall returns it — user and assistant message content, tool arguments and results (command output, file contents), the full system prompt and tool schemas (`request/header`), todo text, compaction summaries, hook `stderrSummary`, feedback text, and the session `cwd` (a local path). The seam ships no redaction rules: with no `telemetry/record` listener mounted, that is the raw captured copy, so a deployment exporting beyond a trusted boundary mounts its own rules (see [the seam README](../session-telemetry/README.md#the-redact-waterfall)). Provider credentials never appear regardless: adapter API keys are constructor parameters, not session events, so they are structurally absent from the log and therefore from telemetry. `DISABLED` does not construct the SDK pipeline or hand any capture to a backend. ## Field mapping -Seam record → SDK log record: `time` → `timestamp`/`observedTimestamp`; `severity` → `severityNumber`/`severityText` (INFO 9 / WARN 13 / ERROR 17); `body` → the structured log body; `attributes` verbatim. Receivers dedupe on `(session.id, event.seq)`, alert on severity, and detect crashes by `shutdown`-record absence (a session with activity, no `shutdown` ops record, gone stale ended uncleanly). The marker means telemetry stopped observing the session cleanly — emitted at the session's own disposal, or at application teardown for sessions still running then; a marker followed by more of that session's events is a telemetry reload, not a session restart. Streams are not self-contained across lineage: a resumed session continues its own id's stream from where the previous process left off, and a forked session's stream starts at its inherited boundary — its prefix lives in the parent's stream, stitched via `session.parent_id` + `session.seed_length`. One consequence of continuing rather than replaying: a turn left open mid-stream and never closed marks the previous process dying inside it. The local log is repaired with synthetic closers at resume, but those repairs are never exported — the wire stream stays faithful to what the crashed process actually shipped, and a later clean `shutdown` marker attests only to the resumed process's own exit. +Seam record → SDK log record: `time` → `timestamp`/`observedTimestamp`; `severity` → `severityNumber`/`severityText` (INFO 9 / WARN 13 / ERROR 17); `body` → the structured log body; `attributes` verbatim. Receivers dedupe on `(session.id, event.seq)` and alert on severity. In `FULL`, they may also detect crashes by `shutdown`-record absence: the marker is emitted at the session's own disposal or application teardown, and a marker followed by more events is a telemetry reload. In `FEEDBACK_ONLY`, a released prefix normally has no later `shutdown` marker, so its absence is not a crash signal. Streams are not self-contained across lineage: a resumed session continues its own id's stream from where the previous process left off, and a forked session's stream starts at its inherited boundary — its prefix lives in the parent's stream, stitched via `session.parent_id` + `session.seed_length`. A resumed local log may contain synthetic closers that were never exported; the wire stream stays faithful to records actually handed to the SDK. ## Model Experience @@ -39,3 +46,4 @@ None; this package neither assembles nor sends a provider request. - **Upstream experimental tree** — `@opentelemetry/sdk-logs` is still published from the upstream experimental tree; SDK API churn lands here and only here — the seam contract does not move. - **No live-collector coverage** — every test exports to a local mock collector; the keyless Loader-composition e2e (`tests/loader-composition.e2e.ts`) covers the wire shape on every run, and behavior against a real OTLP deployment (auth, TLS, throttling) is the SDK exporter's documented territory. +- **Feedback-only memory** — each session retains deep-copied, redacted projected records in memory until feedback releases them or the session becomes unreachable. There is no durable pre-feedback spool; a crash before feedback uploads nothing. diff --git a/packages/telemetry/session-telemetry-otel/README.zh.md b/packages/telemetry/session-telemetry-otel/README.zh.md index 76de1bf1ad..ab01911888 100644 --- a/packages/telemetry/session-telemetry-otel/README.zh.md +++ b/packages/telemetry/session-telemetry-otel/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -[遥测(telemetry)seam](../session-telemetry/) 的 OpenTelemetry 后端,也是部署方唯一要加载的条目。它原样组合 OTel JS SDK(`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP 日志导出器),把 seam 交接过来的每条记录映射到 `logger.emit()`,并使用两个插桩作用域(instrumentation scope):ledger 记录挂在 `@deepseek-ai/dsh-session-telemetry-otel` 下,运维记录挂在 `@deepseek-ai/dsh-session-telemetry-otel/ops` 下。资源身份(`service.name`/`service.version`)来自 `dsh-llm` 的 `APP_IDENTITY`,与归因标头同源。 +[遥测(telemetry)seam](../session-telemetry/) 的 OpenTelemetry 后端,也是部署方唯一要加载的条目。其 `mode` 决定 seam 是立即交接记录、仅在记录反馈时释放记录,还是将遥测留在本地。上传模式会原样组合 OTel JS SDK(`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP 日志导出器),把每条已交接记录映射到 `logger.emit()`,并使用两个插桩作用域(instrumentation scope):ledger 记录挂在 `@deepseek-ai/dsh-session-telemetry-otel` 下,运维记录挂在 `@deepseek-ai/dsh-session-telemetry-otel/ops` 下。资源身份(`service.name`/`service.version`)来自 `dsh-llm` 的 `APP_IDENTITY`,与归因标头同源。 ## 配置 @@ -10,6 +10,7 @@ - id: telemetry-otel name: '@deepseek-ai/dsh-session-telemetry-otel' config: + mode: FULL # FULL (default), FEEDBACK_ONLY, or DISABLED exporter: # passed verbatim to the SDK's OTLP/HTTP log exporter url: https://collector.example.com/v1/logs headers: @@ -17,15 +18,21 @@ processor: {} # optional; passed verbatim to BatchLogRecordProcessor ``` -`exporter.url` 是本包(package)唯一自行校验的字段:必填、无默认值、必须能解析为 `http(s)`,因此缺失端点会在插件加载时失败(`processor.maxExportBatchSize` 不是正整数时同样如此:SDK 会接受该值,随后却在关闭时因它挂起)。其余全部是 SDK 自己的选项形态,由 SDK 拥有并在 SDK 文档中说明,两个配置块都整体透传(passthrough):`OTLPExporterNodeConfigBase` 的每个字段(`headers`、`timeoutMillis`、`compression`、`keepAlive` 等)都会到达导出器;批处理、导出节奏(`scheduledDelayMillis`)、重试、队列上限,以及持续失败下的丢失策略,都是 SDK 的文档化行为,经 `processor` 透传调优。该后端刻意不实现 `flush()`:批处理器是进程内唯一执行 flush 的组件,`shutdown()` 的排空正因如此才是完整的。从 `cordis.yml` 中删除该配置块即为退出方式:无残留状态,也没有 `enabled` 开关。 +| `mode` | 行为 | +|---|---| +| `FULL` | 默认值。每条已投影记录都立即交给 OTel SDK,包括生命周期运维记录。 | +| `FEEDBACK_ONLY` | 每个 `feedback/record` 都会释放截至该事件的已脱敏、已投影会话前缀。后续记录等待下一个反馈事件;如果没有后续反馈,则留在本地。 | +| `DISABLED` | 不构造协调器、提供方、处理器或导出器。没有遥测记录会离开进程。`feedback/record` 会记录 `session telemetry is DISABLED; nothing will be shared and this feedback remains local`;该事件留在本地会话日志中。 | + +`exporter.url` 在 `FULL` 与 `FEEDBACK_ONLY` 中必填,无默认值,且必须能解析为 `http(s)`;在 `DISABLED` 中可省略且不使用。上传模式也会拒绝不是正整数的 `processor.maxExportBatchSize`,SDK 虽会接受该值,但随后会在关闭时挂起。其余全部是 SDK 自己的选项形态,由 SDK 拥有并在 SDK 文档中说明,两个配置块都整体透传(passthrough):`OTLPExporterNodeConfigBase` 的每个字段(`headers`、`timeoutMillis`、`compression`、`keepAlive` 等)都会到达导出器;批处理、导出节奏(`scheduledDelayMillis`)、重试、队列上限,以及持续失败下的丢失策略,都是 SDK 的文档化行为,经 `processor` 透传调优。该后端刻意不实现 `flush()`:批处理器是进程内唯一执行 flush 的组件,`shutdown()` 的排空正因如此才是完整的。 ## 哪些数据会离开本机 -记录携带完整的 `event.data`,内容以 seam 的 `telemetry/record` waterfall(瀑布式事件)返回的结果为准:用户与 assistant 消息内容、工具参数与工具结果(命令输出、文件内容)、完整的系统提示词与工具 schema(`request/header`)、todo 文本、压缩(compaction)摘要、钩子的 `stderrSummary`,以及会话 `cwd`(一个本地路径)。seam 不带任何脱敏规则:未挂载 `telemetry/record` 监听器时,导出的就是捕获原样的副本,因此向可信边界之外导出的部署方要挂载自己的规则(见 [seam README](../session-telemetry/README.md#the-redact-waterfall))。无论如何,提供方凭据都不会出现:适配器的 API key 是构造函数参数而非会话事件,因此它们在结构上就不存在于日志中,也就不存在于遥测中。 +在上传模式中,记录携带完整的 `event.data`,内容以 seam 的 `telemetry/record` waterfall(瀑布式事件)返回的结果为准:用户与 assistant 消息内容、工具参数与工具结果(命令输出、文件内容)、完整的系统提示词与工具 schema(`request/header`)、todo 文本、压缩(compaction)摘要、钩子的 `stderrSummary`、反馈文本,以及会话 `cwd`(一个本地路径)。seam 不带任何脱敏规则:未挂载 `telemetry/record` 监听器时,导出的就是捕获原样的副本,因此向可信边界之外导出的部署方要挂载自己的规则(见 [seam README](../session-telemetry/README.md#the-redact-waterfall))。无论如何,提供方凭据都不会出现:适配器的 API key 是构造函数参数而非会话事件,因此它们在结构上就不存在于日志中,也就不存在于遥测中。`DISABLED` 不会构造 SDK 流水线,也不会将任何捕获内容交给后端。 ## 字段映射 -seam 记录 → SDK 日志记录:`time` → `timestamp`/`observedTimestamp`;`severity` → `severityNumber`/`severityText`(INFO 9 / WARN 13 / ERROR 17);`body` → 结构化日志 body;`attributes` 原样照搬。接收端基于 `(session.id, event.seq)` 去重、按严重级别告警,并通过 `shutdown` 记录的缺失检测崩溃(一个曾有活动、没有 `shutdown` 运维记录、且已然陈旧的会话,就是未干净结束的会话)。该标记的含义是遥测干净地停止了对该会话的观察:它在会话自身 dispose(资源释放)时发出,对于届时仍在运行的会话,则在应用关闭时发出;标记之后又出现该会话的更多事件,说明发生的是遥测重载,而不是会话重启。跨谱系(lineage)的流并不自足:恢复的会话在其自身 id 的流上从上一个进程停止之处继续;fork 出的会话,其流从继承边界开始,前缀位于父会话的流中,由接收端基于 `session.parent_id` + `session.seed_length` 拼接。继续而非回放的一个后果:流中一个开启后再未关闭的轮次,标志着上一个进程死在了该轮次之内。恢复时本地日志会以合成的关闭事件修复,但这些修复绝不导出:导出的流忠实于崩溃进程实际发出的内容,其后干净的 `shutdown` 标记也只证明恢复后进程自身的退出。 +seam 记录 → SDK 日志记录:`time` → `timestamp`/`observedTimestamp`;`severity` → `severityNumber`/`severityText`(INFO 9 / WARN 13 / ERROR 17);`body` → 结构化日志 body;`attributes` 原样照搬。接收端基于 `(session.id, event.seq)` 去重,并按严重级别告警。在 `FULL` 中,接收端还可通过缺少 `shutdown` 记录检测崩溃:该标记在会话自身 dispose(资源释放)或应用关闭时发出;标记之后出现更多事件,说明遥测发生了重载。在 `FEEDBACK_ONLY` 中,已释放的前缀通常不包含随后的 `shutdown` 标记,因此缺少该标记不是崩溃信号。跨谱系(lineage)的流并不自足:恢复的会话在其自身 id 的流上从上一个进程停止之处继续;fork 出的会话的流从继承边界开始,其前缀位于父会话的流中,由接收端基于 `session.parent_id` + `session.seed_length` 拼接。恢复后的本地日志可能包含从未导出的合成关闭事件;协议流忠实于实际交给 SDK 的记录。 ## 模型体验 @@ -39,3 +46,4 @@ seam 记录 → SDK 日志记录:`time` → `timestamp`/`observedTimestamp`; - **上游实验性源码树**:`@opentelemetry/sdk-logs` 仍从上游实验性(experimental)源码树发布;SDK API 的变动只会落在本包,也仅落在本包;seam 契约不动。 - **无真实 collector 覆盖**:所有测试都导出到本地 mock collector;无密钥的 Loader 组合 e2e(`tests/loader-composition.e2e.ts`)在每次运行中都覆盖协议格式(wire format)形态,而面对真实 OTLP 部署的行为(认证、TLS、限流)属于 SDK 导出器文档的职责范围。 +- **仅反馈模式的内存占用**:每个会话都会在内存中保留已深拷贝、已脱敏的投影记录,直到反馈将其释放或会话变得不可达。反馈前不存在持久化 spool;如果在反馈前崩溃,则什么都不上传。 diff --git a/packages/telemetry/session-telemetry-otel/package.json b/packages/telemetry/session-telemetry-otel/package.json index 7be8c04ce4..4037cfe28a 100644 --- a/packages/telemetry/session-telemetry-otel/package.json +++ b/packages/telemetry/session-telemetry-otel/package.json @@ -36,6 +36,7 @@ "schemastery": "^3.18.0" }, "peerDependencies": { + "@deepseek-ai/dsh-command-feedback": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", @@ -44,6 +45,7 @@ }, "devDependencies": { "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-command-feedback": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/telemetry/session-telemetry-otel/src/index.ts b/packages/telemetry/session-telemetry-otel/src/index.ts index 85dd75f275..cb0ee71fc7 100644 --- a/packages/telemetry/session-telemetry-otel/src/index.ts +++ b/packages/telemetry/session-telemetry-otel/src/index.ts @@ -6,8 +6,8 @@ * record handed over by the seam onto `logger.emit()`. Per the seam's * boundary axiom, everything downstream of that call (batching, retry, * queueing, loss policy) is the SDK's documented behavior, configured - * verbatim through the `exporter`/`processor` passthroughs; this package - * adds no knobs of its own on top of them. + * verbatim through the `exporter`/`processor` passthroughs. This package owns + * only whether capture is immediate, feedback-released, or disabled. * * @module @deepseek-ai/dsh-session-telemetry-otel */ @@ -15,7 +15,14 @@ import { createRequire } from 'node:module' import z from 'schemastery' import type { Context } from 'cordis' -import { Telemetry, TelemetryCoordinator, type TelemetryRecord, type TelemetrySeverity } from '@deepseek-ai/dsh-session-telemetry' +import type {} from '@deepseek-ai/dsh-command-feedback' +import { + Telemetry, + TelemetryCoordinator, + type TelemetryDelivery, + type TelemetryRecord, + type TelemetrySeverity, +} from '@deepseek-ai/dsh-session-telemetry' import { APP_IDENTITY } from '@deepseek-ai/dsh-llm' import { BatchLogRecordProcessor, @@ -31,13 +38,22 @@ import { resourceFromAttributes } from '@opentelemetry/resources' // version (same pattern as dsh-llm's attribution identity). const { version } = createRequire(import.meta.url)('../package.json') as { version: string } +/** Supported session-sharing policies for the OTel backend. */ +export const TELEMETRY_MODES = ['FULL', 'FEEDBACK_ONLY', 'DISABLED'] as const + +/** Session-sharing policy selected by {@link Config.mode}. */ +export type TelemetryMode = typeof TELEMETRY_MODES[number] + +const DISABLED_FEEDBACK_WARNING = 'session telemetry is DISABLED; nothing will be shared and this feedback remains local' + /** - * Plugin configuration: two verbatim SDK option shapes plus nothing else. - * `exporter.url` is the one field this package validates itself — required, - * no default, must parse as an `http(s)` URL — because a missing endpoint - * must fail at plugin load, not at first export. + * Plugin configuration: one sharing policy plus two verbatim SDK option + * shapes. `exporter.url` is required for modes that upload and unused for + * `DISABLED`. */ export interface Config { + /** Sharing policy; defaults to immediate `FULL` delivery. */ + mode?: TelemetryMode /** * Passed verbatim to the SDK's OTLP/HTTP log exporter — the complete * `OTLPExporterNodeConfigBase` shape (`headers`, `timeoutMillis`, @@ -45,7 +61,7 @@ export interface Config { * is the one field this package requires and validates itself. */ exporter?: OTLPExporterNodeConfigBase & { - /** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required; validated at plugin load. */ + /** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required outside `DISABLED`; validated at load. */ url?: string } /** @@ -57,13 +73,14 @@ export interface Config { /** * Schemastery validator for {@link Config}; cordis runs it before the plugin - * starts. Shape-level only — the load-bearing `exporter.url` check lives in - * the constructor so its error message names the field. Both slots are opaque - * passthroughs: the SDK owns their shapes and validates its own options; - * re-declaring them field-by-field here would violate the boundary axiom - * (and silently drop every field not re-declared). + * starts. Shape-level only — the mode-dependent `exporter.url` check lives in + * the constructor so its error message names the field. Both SDK slots are + * opaque passthroughs: the SDK owns their shapes and validates its own + * options; re-declaring them field-by-field here would violate the boundary + * axiom (and silently drop every field not re-declared). */ export const Config: z<Config> = z.object({ + mode: z.union(TELEMETRY_MODES).default('FULL'), exporter: z.any(), processor: z.any(), }) @@ -76,22 +93,32 @@ const SEVERITY: Record<TelemetrySeverity, { severityNumber: SeverityNumber; seve } /** - * The backend plugin — the only entry a deployment loads. Constructing it - * wires the SDK pipeline, registers the `telemetry` service (duplicate load - * throws, cordis' standard duplicate-service behavior), and composes the - * seam's {@link TelemetryCoordinator}, which installs the capture side onto - * this fiber. + * The backend plugin — the only entry a deployment loads. It always registers + * the `telemetry` service (duplicate load throws). Uploading modes wire the SDK + * pipeline and compose {@link TelemetryCoordinator}; `DISABLED` constructs no + * SDK state and listens only to warn when recorded feedback stays local. */ export class TelemetryOtel extends Telemetry { static inject = ['sessions'] static Config = Config - private readonly provider: LoggerProvider - private readonly ledger: Logger - private readonly ops: Logger + private readonly provider: LoggerProvider | undefined + private readonly ledger: Logger | undefined + private readonly ops: Logger | undefined constructor(ctx: Context, config: Config) { super(ctx) + const mode = config.mode ?? 'FULL' + if (mode === 'DISABLED') { + this.provider = undefined + this.ledger = undefined + this.ops = undefined + ctx.on('session/event', (_session, event) => { + if (event.type === 'feedback/record') ctx.logger.warn(DISABLED_FEEDBACK_WARNING) + }) + return + } + const url = config.exporter?.url if (url === undefined || url.length === 0) { throw new Error('session-telemetry-otel: exporter.url is required (the full OTLP logs endpoint)') @@ -134,16 +161,26 @@ export class TelemetryOtel extends Telemetry { }) this.ledger = this.provider.getLogger('@deepseek-ai/dsh-session-telemetry-otel', version) this.ops = this.provider.getLogger('@deepseek-ai/dsh-session-telemetry-otel/ops', version) - new TelemetryCoordinator(ctx, this) + const delivery: TelemetryDelivery = mode === 'FULL' ? 'immediate' : 'held' + const coordinator = new TelemetryCoordinator(ctx, this, delivery) + if (mode === 'FEEDBACK_ONLY') { + // The coordinator listener is registered first, so a feedback event + // enters the held prefix before this listener releases that exact prefix. + ctx.on('session/event', (session, event) => { + if (event.type === 'feedback/record') coordinator.release(session) + }) + } } /** * Map one seam record onto the SDK logger for its channel — a synchronous - * enqueue into the batch processor's queue. + * enqueue into the batch processor's queue. Direct calls are no-ops in + * `DISABLED`, where no coordinator or SDK pipeline exists. * @param record - the logical record handed over by the coordinator. */ emit(record: TelemetryRecord): void { const logger = record.channel === 'ops' ? this.ops : this.ledger + if (logger === undefined) return logger.emit({ timestamp: record.time, observedTimestamp: record.time, @@ -167,14 +204,15 @@ export class TelemetryOtel extends Telemetry { /** * Delegate disposal to the SDK's shutdown contract: drain the queue and * quiesce. With no concurrent `forceFlush()` in the process (see above), - * shutdown's internal drain is complete — everything emitted before this - * call, including the coordinator's dispose-time `shutdown` markers, is - * exported before the exporter closes. Awaited (and error-contained) by - * the coordinator's disposer. + * shutdown's internal drain is complete — everything handed to the SDK + * before this call is exported before the exporter closes. In `FULL`, that + * includes dispose-time `shutdown` markers; held suffixes never reach the + * SDK. Awaited (and error-contained) by the coordinator's disposer. A + * disabled backend resolves immediately. * @returns resolves when the SDK pipeline has quiesced. */ shutdown(): Promise<void> { - return this.provider.shutdown() + return this.provider === undefined ? Promise.resolve() : this.provider.shutdown() } } diff --git a/packages/telemetry/session-telemetry-otel/src/invariant.ts b/packages/telemetry/session-telemetry-otel/src/invariant.ts index 075e5cc193..030b7ce670 100644 --- a/packages/telemetry/session-telemetry-otel/src/invariant.ts +++ b/packages/telemetry/session-telemetry-otel/src/invariant.ts @@ -15,10 +15,9 @@ export const name = 'session-telemetry-otel-invariant' export const inject = ['invariants'] /** - * No runtime invariant: the backend forwards seam records into the OTel SDK's - * in-process pipeline and appends nothing to any session; its only observable - * effects (batching, export) happen inside the SDK past the seam's boundary - * axiom, out of reach of an independent companion. + * No runtime invariant: mode selection changes capture handoff, SDK setup, and + * local diagnostics without mutating session or service state an independent + * companion can compare. Export remains inside the SDK past the seam boundary. */ const install: InvariantInstaller = () => {} diff --git a/packages/telemetry/session-telemetry-otel/tests/loader-composition.e2e.ts b/packages/telemetry/session-telemetry-otel/tests/loader-composition.e2e.ts index 8f16662614..e07e05fed9 100644 --- a/packages/telemetry/session-telemetry-otel/tests/loader-composition.e2e.ts +++ b/packages/telemetry/session-telemetry-otel/tests/loader-composition.e2e.ts @@ -40,6 +40,11 @@ interface OtlpCapture { }[] } +interface FixtureOutput { + captures: OtlpCapture[] + logContent: string +} + async function jsonlFiles(dir: string): Promise<string[]> { const entries = await readdir(dir, { withFileTypes: true }) const paths = await Promise.all(entries.map(async (entry) => { @@ -50,10 +55,29 @@ async function jsonlFiles(dir: string): Promise<string[]> { return paths.flat() } +async function readFixtureOutput(cwd: string): Promise<FixtureOutput> { + const captures = JSON.parse(await readFile(join(cwd, 'otlp-captures.json'), 'utf8')) as OtlpCapture[] + const logs = await jsonlFiles(join(cwd, '.sessions')) + expect(logs).toHaveLength(1) + return { captures, logContent: await readFile(logs[0] as string, 'utf8') } +} + +function allRecords(captures: OtlpCapture[]) { + return captures.flatMap(capture => capture.resourceLogs.flatMap(resource => + resource.scopeLogs.flatMap(scoped => scoped.logRecords.map(record => ({ scope: scoped.scope.name, record }))))) +} + +function eventTypes(captures: OtlpCapture[]): string[] { + return allRecords(captures).flatMap(({ record }) => + record.attributes?.flatMap(attribute => + attribute.key === 'event.type' && typeof attribute.value['stringValue'] === 'string' + ? [attribute.value['stringValue']] + : []) ?? []) +} + describe('session-telemetry-otel through a real headless cordis.yml', () => { it('exports redacted ledger records to the collector while the canonical log keeps the secret', async () => { - let captures: OtlpCapture[] = [] - let logContent = '' + let output!: FixtureOutput const { stderr } = await runLoaderSmoke({ label: 'session-telemetry-otel loader smoke', tempDirPrefix: 'telemetry-otel-e2e-', @@ -61,39 +85,70 @@ describe('session-telemetry-otel through a real headless cordis.yml', () => { libBinScript: driver, configPath, tsconfigPath: repoTsconfig, - inspect: async (cwd) => { - captures = JSON.parse(await readFile(join(cwd, 'otlp-captures.json'), 'utf8')) as OtlpCapture[] - const logs = await jsonlFiles(join(cwd, '.sessions')) - expect(logs).toHaveLength(1) - logContent = await readFile(logs[0] as string, 'utf8') - }, + inspect: async (cwd) => { output = await readFixtureOutput(cwd) }, }) expect(stderr).not.toContain('UNHANDLED') - const records = captures.flatMap(capture => capture.resourceLogs.flatMap(resource => - resource.scopeLogs.flatMap(scoped => scoped.logRecords.map(record => ({ scope: scoped.scope.name, record }))))) + const records = allRecords(output.captures) expect(records.length).toBeGreaterThan(0) - const eventTypes = records.flatMap(({ record }) => - record.attributes?.flatMap(attribute => - attribute.key === 'event.type' && typeof attribute.value['stringValue'] === 'string' - ? [attribute.value['stringValue']] - : []) ?? []) + const types = eventTypes(output.captures) for (const expected of ['turn/start', 'user/message', 'tool/call', 'tool/result', 'assistant/message', 'turn/end']) { - expect(eventTypes, expected).toContain(expected) + expect(types, expected).toContain(expected) } expect(records.some(({ scope }) => scope.endsWith('/ops'))).toBe(true) // The deployment-mounted rule on the wire: the fixture credential never // leaves the process, its surrounding prose does, and the placeholder // marks the spot — the seam itself ships no rules. - const wire = JSON.stringify(captures) + const wire = JSON.stringify(output.captures) expect(wire).not.toContain(FIXTURE_SECRET) expect(wire).toContain(FIXTURE_PLACEHOLDER) expect(wire).toContain('prove telemetry with key') // The canonical session log is never rewritten. - expect(logContent).toContain(FIXTURE_SECRET) - expect(logContent).not.toContain(FIXTURE_PLACEHOLDER) + expect(output.logContent).toContain(FIXTURE_SECRET) + expect(output.logContent).not.toContain(FIXTURE_PLACEHOLDER) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + + it('exports only prefixes ending in feedback under feedback-only mode', async () => { + let output!: FixtureOutput + const { stderr } = await runLoaderSmoke({ + label: 'session-telemetry-otel feedback-only loader smoke', + tempDirPrefix: 'telemetry-otel-feedback-e2e-', + binScript: driver, + libBinScript: driver, + configPath, + tsconfigPath: repoTsconfig, + env: { DSH_TELEMETRY_E2E_MODE: 'FEEDBACK_ONLY' }, + inspect: async (cwd) => { output = await readFixtureOutput(cwd) }, + }) + expect(stderr).not.toContain('UNHANDLED') + + const wire = JSON.stringify(output.captures) + expect(eventTypes(output.captures)).toContain('feedback/record') + expect(wire).toContain('fixture feedback') + expect(wire).toContain('prove telemetry with key') + expect(wire).not.toContain('post-feedback private suffix') + expect(output.logContent).toContain('post-feedback private suffix') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + + it('keeps disabled feedback local and prints the stable warning', async () => { + let output!: FixtureOutput + const { stdout } = await runLoaderSmoke({ + label: 'session-telemetry-otel disabled loader smoke', + tempDirPrefix: 'telemetry-otel-disabled-e2e-', + binScript: driver, + libBinScript: driver, + configPath, + tsconfigPath: repoTsconfig, + env: { DSH_TELEMETRY_E2E_MODE: 'DISABLED' }, + inspect: async (cwd) => { output = await readFixtureOutput(cwd) }, + }) + + expect(output.captures).toEqual([]) + expect(output.logContent).toContain('fixture feedback') + expect(stdout.match(/session telemetry is DISABLED; nothing will be shared and this feedback remains local/)?.[0]) + .toMatchInlineSnapshot('"session telemetry is DISABLED; nothing will be shared and this feedback remains local"') }, LOADER_SMOKE_TEST_TIMEOUT_MS) }) diff --git a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts index cccb90ed43..18c466f7aa 100644 --- a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts +++ b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts @@ -5,12 +5,13 @@ * for the default-exported Service class. */ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { createServer, type Server } from 'node:http' import { once } from 'node:events' import { gunzipSync } from 'node:zlib' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' +import { recordFeedback } from '@deepseek-ai/dsh-command-feedback' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import TelemetryOtel, { Config } from '../src/index.ts' @@ -30,6 +31,7 @@ interface OtlpLogsRequest { severityNumber: number severityText: string attributes?: { key: string; value: Record<string, unknown> }[] + body?: unknown }[] }[] }[] @@ -88,6 +90,14 @@ function allRecords(captures: Capture[]) { s.logRecords.map(record => ({ scope: s.scope.name, record }))))) } +function eventTypes(captures: Capture[]): string[] { + return allRecords(captures).flatMap(({ record }) => + record.attributes?.flatMap(attribute => + attribute.key === 'event.type' && typeof attribute.value['stringValue'] === 'string' + ? [attribute.value['stringValue']] + : []) ?? []) +} + describe('TelemetryOtel wire', () => { it('ships session records and the ops shutdown marker through the real SDK pipeline', async () => { const { url, captures } = await mockCollector() @@ -195,6 +205,82 @@ describe('TelemetryOtel wire', () => { r.record.attributes?.some(a => a.key === 'event.type' && a.value.stringValue === 'turn/start')) expect(start?.record.severityNumber).toBe(13) }) + + it('holds each session suffix until the next feedback event', async () => { + const { url, captures } = await mockCollector() + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(TelemetryOtel, { + mode: 'FEEDBACK_ONLY', + exporter: { url }, + }) + const session = ctx.sessions.create(SessionId('feedback-only'), { meta: {} }) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + recordFeedback(session, 'first report') + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + recordFeedback(session, 'second report') + session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + await fiber.dispose() + + const types = allRecords(captures).flatMap(({ record }) => + record.attributes?.flatMap(attribute => + attribute.key === 'event.type' ? [attribute.value.stringValue] : []) ?? []) + expect(types).toEqual(['turn/start', 'feedback/record', 'turn/end', 'feedback/record']) + expect(JSON.stringify(captures)).toContain('first report') + expect(JSON.stringify(captures)).toContain('second report') + expect(allRecords(captures).some(({ scope }) => scope.endsWith('/ops'))).toBe(false) + }) + + it('sends no request when feedback-only mode ends without feedback', async () => { + const { url, captures } = await mockCollector() + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(TelemetryOtel, { + mode: 'FEEDBACK_ONLY', + exporter: { url }, + }) + const session = ctx.sessions.create(SessionId('no-feedback'), { meta: {} }) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + await fiber.dispose() + expect(captures).toEqual([]) + }) + + it('boots disabled without exporter config and warns when feedback stays local', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const fiber = await ctx.plugin(TelemetryOtel, { mode: 'DISABLED' }) + const session = ctx.sessions.create(SessionId('disabled'), { meta: {} }) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + recordFeedback(session, 'local report') + + expect(warn).toHaveBeenCalledWith( + 'session telemetry is DISABLED; nothing will be shared and this feedback remains local', + ) + ctx.telemetry.emit({ + channel: 'ledger', + time: 0, + severity: 'info', + attributes: {}, + body: null, + }) + await ctx.telemetry.shutdown() + await fiber.dispose() + recordFeedback(session, 'after disposal') + expect(warn).toHaveBeenCalledTimes(1) + }) + + it('defaults direct construction to full delivery', async () => { + const { url, captures } = await mockCollector() + const ctx = new Context() + await ctx.plugin(SessionStore) + new TelemetryOtel(ctx, { exporter: { url } }) + const session = ctx.sessions.create(SessionId('direct-default'), { meta: {} }) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + await ctx.fiber.dispose() + + expect(eventTypes(captures)).toContain('turn/start') + }) }) describe('TelemetryOtel config fails loud', () => { @@ -203,6 +289,8 @@ describe('TelemetryOtel config fails loud', () => { [{ exporter: { url: '' } }, /exporter\.url is required/], [{ exporter: { url: 'not a url' } }, /not a valid URL/], [{ exporter: { url: 'ftp://collector' } }, /must be http\(s\)/], + [{ mode: 'FEEDBACK_ONLY' }, /exporter\.url is required/], + [{ mode: 'INVALID' }, /INVALID/], // The SDK accepts a non-positive batch size but its shutdown drain then // splices empty batches forever — dispose would hang, so reject at load. [{ exporter: { url: 'http://c/v1/logs' }, processor: { maxExportBatchSize: 0 } }, /maxExportBatchSize/], diff --git a/packages/telemetry/session-telemetry-otel/tsconfig.json b/packages/telemetry/session-telemetry-otel/tsconfig.json index 9512133cf7..4ba93f9eb1 100644 --- a/packages/telemetry/session-telemetry-otel/tsconfig.json +++ b/packages/telemetry/session-telemetry-otel/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../../core/session" }, + { + "path": "../../feedback/command-feedback" + }, { "path": "../../llm/llm" }, diff --git a/packages/telemetry/session-telemetry/README.i18n.yaml b/packages/telemetry/session-telemetry/README.i18n.yaml index 18f6751424..da3a62e2fd 100644 --- a/packages/telemetry/session-telemetry/README.i18n.yaml +++ b/packages/telemetry/session-telemetry/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/telemetry/session-telemetry/README.md -README.md: 272c9abe78849be3d2bba2c54cd7e25bcbe2d4c2 -README.zh.md: e6f077c1d12d00e746147908560d05381fde11c3 +README.md: d38433a728c699c7fb3cc0512bb6a2d977dd4cc6 +README.zh.md: 3a86b01321fc7dfd33d39530ee7fa38a6ee1f2dc diff --git a/packages/telemetry/session-telemetry/README.md b/packages/telemetry/session-telemetry/README.md index 272c9abe78..d38433a728 100644 --- a/packages/telemetry/session-telemetry/README.md +++ b/packages/telemetry/session-telemetry/README.md @@ -2,23 +2,23 @@ English | [中文](README.zh.md) -The telemetry seam: the CAPTURE side of session-event reporting, behind a backend contract any reporting SDK satisfies with zero bending. The boundary axiom that shapes everything here: **this package's aspect ends at `emit()`** — batching, retry, queueing, and loss policy belong to the backend's SDK and are neither specified nor wrapped. Rationale and rejected alternatives: [the revival Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md). +The telemetry seam: the capture side of session-event reporting, behind a backend contract any reporting SDK satisfies with zero bending. Capture can hand each redacted record over immediately or hold a per-session prefix for an explicit release. The boundary axiom that shapes everything here: **this package's aspect ends at `emit()`** — batching, retry, queueing, and loss policy belong to the backend's SDK and are neither specified nor wrapped. Rationale and rejected alternatives: [the revival Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md) and [feedback-gated delivery](../../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md). ## The backend contract -`TelemetryBackend` is three members: `emit(record)` (MUST be a non-blocking enqueue — it runs synchronously on the `session/event` hot path), optional `flush()` (a turn-boundary hint, fire-and-forget; most backends leave it unimplemented and let their SDK's batching cadence govern export timing — an implementer owns the interaction between concurrent flushes and `shutdown()`'s drain), and `shutdown()` (the lifecycle forward: drain-and-quiesce, awaited at dispose). `Telemetry` is its service-registered form under the `telemetry` context key — one implementation per context, duplicate load throws. A backend composes `TelemetryCoordinator` in its constructor. +`TelemetryBackend` is three members: `emit(record)` (MUST be a non-blocking enqueue — it runs synchronously on the `session/event` hot path, either at capture or held-prefix release), optional `flush()` (a turn-boundary hint, fire-and-forget; most backends leave it unimplemented and let their SDK's batching cadence govern export timing — an implementer owns the interaction between concurrent flushes and `shutdown()`'s drain), and `shutdown()` (the lifecycle forward: drain-and-quiesce, awaited at dispose). `Telemetry` is its service-registered form under the `telemetry` context key — one implementation per context, duplicate load throws. A backend composes `TelemetryCoordinator` with `immediate` delivery or `held` delivery and calls `release(session)` at its owning trigger. ## Capture points -The coordinator registers, all through the composing fiber's effects: `session/created` (adopt: record the header, read the log back through the projection from the construction boundary — constructor seeds from fork/resume never re-emit on the firehose and never re-export), `session/event` (project, deep-copy, redact, hand off; zero I/O), `session/flush` (forward the optional `flush()` hint and return void — the loop's awaited parallel must never wait on telemetry), `session/disposed` (emit the session's `shutdown` operational record at its own termination edge — where receivers key crash detection — then retire it, so a long-lived backend neither retains closed sessions nor re-marks them at unload), `agent/error` (the one live-bus relay; the session event vocabulary intentionally has no operational-error record), a dispose effect (mark each session still alive at teardown, then await the backend's `shutdown()`; failures warn instead of throwing), and an adoption sweep of `ctx.sessions.list()` (a hot reload does not replay `session/created`). +The coordinator registers, all through the composing fiber's effects: `session/created` (adopt: record the header, read the log back through the projection from the construction boundary — constructor seeds from fork/resume never re-emit on the firehose and never re-export), `session/event` (project, deep-copy, redact, then hand off or hold; zero I/O), `session/flush` (forward the optional `flush()` hint and return void — the loop's awaited parallel must never wait on telemetry), `session/disposed` (capture the session's `shutdown` operational record at its termination edge, then retire it), `agent/error` (the one live-bus relay; the session event vocabulary intentionally has no operational-error record), a dispose effect (capture shutdown for each still-live session, then await the backend's `shutdown()`; failures warn instead of throwing), and an adoption sweep of `ctx.sessions.list()` (a hot reload does not replay `session/created`). Immediate delivery hands lifecycle records over; held delivery leaves any suffix after the last release local, including its later shutdown marker. ## The redact waterfall -Every record passes the `telemetry/record` waterfall between projection and `emit()` — the seam's scrubbing extension point. The seam ships NO rules of its own: the innermost `next()` passes the record through unchanged, so with no listener mounted records reach the backend exactly as captured, and exported data is precisely as clean as the rules a deployment mounts. Listeners stack by transforming `next()`'s return value; returning without `next()` replaces everything beneath, and a throwing listener withholds that one record fail-closed inside the coordinator's containment. Redaction applies to the exported copy only; the canonical session log is never rewritten. +Every record passes the `telemetry/record` waterfall immediately after projection — the seam's scrubbing extension point. The seam ships NO rules of its own: the innermost `next()` passes the record through unchanged, so with no listener mounted records reach the backend exactly as captured, and exported data is precisely as clean as the rules a deployment mounts. Listeners stack by transforming `next()`'s return value; returning without `next()` replaces everything beneath, and a throwing listener withholds that one record fail-closed inside the coordinator's containment. Held delivery stores only the waterfall result, so later policy removal cannot expose the original capture. Redaction applies to the outbound copy only; the canonical session log is never rewritten. ## The handoff cursor -A module-scope `WeakMap<Session, seq>` marks the highest seq HANDED OFF (not delivered) per session, advanced at emit time. It survives reloads that do not re-evaluate this module — config re-applies and backend source reloads, which is where iteration happens; that asymmetry is why the cursor lives in the seam. On re-adoption the coordinator re-hands only events past the cursor (events at or below it still rebuild the chunk-projection state); a missing cursor safely degrades to a re-hand from the session's construction boundary (`Session.firstLiveSeq` — seq 0 for a session born in this process), absorbed by receiver-side dedupe on `(session.id, event.seq)`. Constructor seeds never re-export: a resumed session's history shipped from the previous process under the same id, and a fork's inherited prefix lives in the parent's stream (receivers stitch on `session.parent_id` + `session.seed_length`). The accepted cost, consistent with at-most-once delivery: a resume does not backfill records a previous process failed to deliver — a deployment with a backfill requirement needs the deferred outbox, not replay. This is a deliberate, narrow exception to the registrations-are-effects discipline: entries die with their sessions, the value is a monotonic watermark, and losing it is never an error. +A module-scope `WeakMap<Session, seq>` marks the highest seq HANDED OFF (not delivered) per session. Immediate delivery advances it at capture; held delivery advances it only when `release(session)` hands that record to the backend. An unreleased prefix therefore survives a coordinator reload through deterministic re-adoption instead of disappearing with its in-memory copy. On re-adoption the coordinator re-hands only events past the cursor (events at or below it still rebuild the chunk-projection state); a missing cursor safely degrades to a re-hand from the session's construction boundary (`Session.firstLiveSeq` — seq 0 for a session born in this process), absorbed by receiver-side dedupe on `(session.id, event.seq)`. Constructor seeds never re-export: a resumed session's history shipped from the previous process under the same id, and a fork's inherited prefix lives in the parent's stream (receivers stitch on `session.parent_id` + `session.seed_length`). The accepted cost, consistent with at-most-once delivery: a resume does not backfill records a previous process failed to deliver — a deployment with a backfill requirement needs the deferred outbox, not replay. This is a deliberate, narrow exception to the registrations-are-effects discipline: entries die with their sessions, the value is a monotonic watermark, and losing it is never an error. ## The fixed chunk projection @@ -40,3 +40,4 @@ None; this package neither assembles nor sends a provider request. - **Best-effort delivery** — the cursor marks handed-off, not delivered; a session torn down inside a reload window cannot be re-adopted; whatever sits in a backend queue at crash time is lost. A durable outbox (spool, per-sink cursors, at-least-once) is deferred until a deployment states a crash-loss requirement — see [the revival Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md). - **No built-in redaction rules** — with no `telemetry/record` listener mounted, records leave the process exactly as captured, including any credentials embedded in file contents or command output; a deployment exporting to a shared collector owns its rule set. +- **Held prefixes duplicate memory** — held delivery retains one deep-copied, redacted record per projected event until release or session collection. It adds no durable outbox and intentionally trades memory for a simple no-upload-before-trigger boundary. diff --git a/packages/telemetry/session-telemetry/README.zh.md b/packages/telemetry/session-telemetry/README.zh.md index e6f077c1d1..3a86b01321 100644 --- a/packages/telemetry/session-telemetry/README.zh.md +++ b/packages/telemetry/session-telemetry/README.zh.md @@ -2,23 +2,23 @@ [English](README.md) | 中文 -遥测(telemetry)seam:会话事件上报的捕获侧,隔在一个后端契约之后,任何上报 SDK 都无需变形即可满足该契约。塑造本包(package)一切设计的边界公理:**本包的职责止于 `emit()`**。批处理、重试、排队与丢失策略都属于后端自身的 SDK,本包既不为其立规,也不做包装。设计依据与被否决的替代方案见[复活 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)。 +遥测(telemetry)seam:会话事件上报的捕获侧,隔在一个后端契约之后,任何上报 SDK 都无需变形即可满足该契约。捕获侧可立即交接每条已脱敏记录,也可按会话暂存一个前缀,等待显式释放。塑造本包(package)一切设计的边界公理:**本包的职责止于 `emit()`**。批处理、重试、排队与丢失策略都属于后端自身的 SDK,本包既不为其立规,也不做包装。设计依据与被否决的替代方案见[复活 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)与[反馈门控投递](../../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md)。 ## 后端契约 -`TelemetryBackend` 只有三个成员:`emit(record)`(必须是非阻塞入队;它在 `session/event` 热路径上同步执行)、可选的 `flush()`(轮次边界提示,触发后不等待结果;多数后端不实现它,而由其 SDK 的批处理节奏决定导出时机;并发 flush 与 `shutdown()` 的排空之间的交互由实现方自行负责)、以及 `shutdown()`(生命周期转发点:排空并完全停稳,在 dispose(资源释放)时被等待)。`Telemetry` 是它注册在 `telemetry` 上下文键下的服务形态:每个上下文只允许一个实现,重复加载会抛出异常。后端在其构造函数中组合 `TelemetryCoordinator`。 +`TelemetryBackend` 只有三个成员:`emit(record)`(必须是非阻塞入队;它会在捕获或暂存前缀释放时,于 `session/event` 热路径上同步执行)、可选的 `flush()`(轮次边界提示,触发后不等待结果;多数后端不实现它,而由其 SDK 的批处理节奏决定导出时机;并发 flush 与 `shutdown()` 的排空之间的交互由实现方自行负责)、以及 `shutdown()`(生命周期转发点:排空并完全停稳,在 dispose(资源释放)时被等待)。`Telemetry` 是它注册在 `telemetry` 上下文键下的服务形态:每个上下文只允许一个实现,重复加载会抛出异常。后端以 `immediate` 或 `held` 投递模式组合 `TelemetryCoordinator`,并在自身所属的触发器中调用 `release(session)`。 ## 捕获点 -协调器的全部注册都经由组合方 fiber 的 effect 完成:`session/created`(收养:记录 header,并经投影从构造边界起回读日志;来自 fork 或恢复的构造函数种子绝不会在 firehose 上再次发出,也绝不会再次导出)、`session/event`(投影、深拷贝、脱敏、交接;零 I/O)、`session/flush`(转发可选的 `flush()` 提示并返回 void;循环所等待的并行任务绝不能等待遥测)、`session/disposed`(在会话自身的终止边缘发出该会话的 `shutdown` 运维记录,接收端正是在这个边缘锚定崩溃检测;随后将该会话退役,因此长生命周期的后端既不会保留已关闭的会话,也不会在卸载时再次标记它们)、`agent/error`(唯一的实时总线转发;会话事件词汇有意不包含运维错误记录)、一个 dispose effect(拆卸时先标记每个仍存活的会话,再等待后端的 `shutdown()`;失败只发出警告而不抛出),以及对 `ctx.sessions.list()` 的收养扫描(热重载不会重放 `session/created`)。 +协调器的全部注册都经由组合方 fiber 的 effect 完成:`session/created`(收养:记录 header,并经投影从构造边界起回读日志;来自 fork 或恢复的构造函数种子绝不会在 firehose 上再次发出,也绝不会再次导出)、`session/event`(投影、深拷贝、脱敏,再交接或暂存;零 I/O)、`session/flush`(转发可选的 `flush()` 提示并返回 void;循环所等待的并行任务绝不能等待遥测)、`session/disposed`(在会话自身的终止边缘捕获该会话的 `shutdown` 运维记录,然后将其退役)、`agent/error`(唯一的实时总线转发;会话事件词汇有意不包含运维错误记录)、一个 dispose effect(捕获每个仍存活会话的 shutdown,再等待后端的 `shutdown()`;失败只发出警告而不抛出),以及对 `ctx.sessions.list()` 的收养扫描(热重载不会重放 `session/created`)。即时投递会交接生命周期记录;暂存投递会将上次释放后的任何后缀留在本地,包括随后的 shutdown 标记。 ## 脱敏 waterfall(瀑布式事件) -每条记录在投影与 `emit()` 之间都要经过 `telemetry/record` waterfall,这是该 seam 的脱敏扩展点。seam 自身不带任何规则:最内层的 `next()` 原样透传记录,因此未挂载监听器时,记录以捕获时的原样到达后端;导出数据能干净到什么程度,恰恰取决于部署方挂载了什么规则。监听器通过变换 `next()` 的返回值来堆叠;不调用 `next()` 就返回,即替换其下方的全部逻辑;抛出异常的监听器会在协调器的隔离范围内以 fail-closed 方式拦下这一条记录。脱敏只作用于导出副本;权威会话日志永不改写。 +每条记录在投影后立即经过 `telemetry/record` waterfall,这是该 seam 的脱敏扩展点。seam 自身不带任何规则:最内层的 `next()` 原样透传记录,因此未挂载监听器时,记录以捕获时的原样到达后端;导出数据能干净到什么程度,恰恰取决于部署方挂载了什么规则。监听器通过变换 `next()` 的返回值来堆叠;不调用 `next()` 就返回,即替换其下方的全部逻辑;抛出异常的监听器会在协调器的隔离范围内以 fail-closed 方式拦下这一条记录。暂存投递只保留 waterfall 的结果,因此后续移除策略也无法暴露捕获时的原始内容。脱敏只作用于外发副本;权威会话日志永不改写。 ## handoff 游标 -一个模块作用域的 `WeakMap<Session, seq>` 记录每个会话已交接(而非已投递)的最高 seq,在 emit 时推进。游标在不重新求值本模块的重载(配置重新应用、后端源码重载)中存活,而迭代恰恰发生在这类重载中;这种不对称正是游标放在 seam 一侧的原因。重新收养时,协调器只重新交接游标之后的事件(游标及其之前的事件仍用于重建分片投影状态);游标缺失时安全退化为从会话构造边界起的重新交接(`Session.firstLiveSeq`,对在本进程中诞生的会话即 seq 0),由接收端基于 `(session.id, event.seq)` 的去重吸收。构造函数种子绝不会再次导出:恢复会话的历史已由上一个进程以同一 id 发出,fork 继承的前缀则位于父会话的流中(接收端基于 `session.parent_id` + `session.seed_length` 拼接)。由此接受的代价与至多一次(at-most-once)投递一致:恢复不会回填上一个进程未能投递的记录;有回填要求的部署需要的是已推迟的 outbox,而不是回放。这是对「注册即 effect」纪律的一次有意且范围极窄的例外:条目随其会话消亡,值是单调水位线,丢失它绝不是错误。 +一个模块作用域的 `WeakMap<Session, seq>` 记录每个会话已交接(而非已投递)的最高 seq。即时投递在捕获时推进游标;暂存投递只有在 `release(session)` 将记录交给后端时才推进游标。因此,重建协调器后会通过确定性重新收养恢复未释放的前缀,而不会随其内存副本一同消失。重新收养时,协调器只重新交接游标之后的事件(游标及其之前的事件仍用于重建分片投影状态);游标缺失时安全退化为从会话构造边界起的重新交接(`Session.firstLiveSeq`,对在本进程中诞生的会话即 seq 0),由接收端基于 `(session.id, event.seq)` 的去重吸收。构造函数种子绝不会再次导出:恢复会话的历史已由上一个进程以同一 id 发出,fork 继承的前缀则位于父会话的流中(接收端基于 `session.parent_id` + `session.seed_length` 拼接)。由此接受的代价与至多一次(at-most-once)投递一致:恢复不会回填上一个进程未能投递的记录;有回填要求的部署需要的是已推迟的 outbox,而不是回放。这是对「注册即 effect」纪律的一次有意且范围极窄的例外:条目随其会话消亡,值是单调水位线,丢失它绝不是错误。 ## 固定分片投影 @@ -40,3 +40,4 @@ - **尽力而为的投递**:游标标记的是已交接而非已投递;在重载窗口内被拆除的会话无法重新收养;崩溃时留在后端队列中的内容会丢失。持久化 outbox(spool、每 sink 游标、at-least-once)推迟到有部署方提出明确的崩溃丢失要求时再实现;见[复活 Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)。 - **不内置脱敏规则**:未挂载 `telemetry/record` 监听器时,记录以捕获时的原样离开进程,包括文件内容或命令输出中内嵌的任何凭据;向共享 collector 导出的部署方自行负责其规则集。 +- **暂存前缀会重复占用内存**:暂存投递会为每个已投影事件保留一份深拷贝且已脱敏的记录,直到释放或回收会话。它不增加持久化 outbox,而是有意以内存换取简单的「触发前不上传」边界。 diff --git a/packages/telemetry/session-telemetry/src/coordinator.ts b/packages/telemetry/session-telemetry/src/coordinator.ts index 0bebbcc561..710e9b81f9 100644 --- a/packages/telemetry/session-telemetry/src/coordinator.ts +++ b/packages/telemetry/session-telemetry/src/coordinator.ts @@ -3,10 +3,11 @@ * firehose plus the one live-bus relay (`agent/error`), applies the fixed * chunk projection, builds logical records, runs each through the * `telemetry/record` waterfall (deployment-mounted redaction rules; - * pass-through when none), and hands the result to the backend — synchronously, with every - * handler self-contained so a failing backend can never starve other - * subscribers (cordis `emit` is stop-on-throw) or touch the agent loop. - * Composed by a backend in its constructor. + * pass-through when none), then hands the result to the backend immediately + * or holds it for explicit release. Every synchronous handler is + * self-contained so a failing backend can never starve other subscribers + * (cordis `emit` is stop-on-throw) or touch the agent loop. Composed by a + * backend in its constructor. * * @module @deepseek-ai/dsh-session-telemetry/coordinator */ @@ -16,6 +17,16 @@ import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import type { TelemetryBackend, TelemetryRecord, TelemetrySeverity } from './index.ts' +/** Whether capture hands records over immediately or holds them for an explicit release. */ +export type TelemetryDelivery = 'immediate' | 'held' + +/** One redacted record waiting at the capture boundary. */ +interface PendingRecord { + readonly record: TelemetryRecord + /** Ledger cursor advanced only after the backend accepts this record. */ + readonly seq?: number +} + /** * The handoff cursor: per session, the highest `seq` handed to a backend. * Deliberately MODULE-scope ambient state — a narrow, documented exception @@ -35,14 +46,13 @@ const handoffCursor = new WeakMap<Session, number>() * Registers the persistence-coordinator listener set plus the `agent/error` * relay, all through `ctx.effect()`/`ctx.on()` on the composing fiber, and * sweeps already-live sessions (a hot reload does not replay - * `session/created`). A `session/disposed` emits the session's `shutdown` - * operational record — the marker rides the session's own termination edge, - * where receivers key crash detection — and retires it from the adopted set, - * so a long-lived backend neither retains closed sessions (and their frozen - * event logs) nor re-marks them at unload. Disposal marks the sessions still - * alive at teardown (their own edge would fire unobserved) and then awaits - * the backend's `shutdown()`; a failure there warns instead of throwing — - * best-effort reporting must not fail application teardown. + * `session/created`). A `session/disposed` captures the session's `shutdown` + * operational record at its own termination edge and retires it from the + * adopted set. Immediate delivery hands that marker over; held delivery keeps + * it local without another explicit release. Disposal captures the same + * marker for sessions still alive, then awaits the backend's `shutdown()`; a + * failure there warns instead of throwing — best-effort reporting must not + * fail application teardown. */ export class TelemetryCoordinator { /** @@ -53,28 +63,30 @@ export class TelemetryCoordinator { private readonly adopted = new Set<Session>() /** Per session, the `turn:step` keys whose first chunk already shipped; rebuilt from the log on re-adoption. */ private readonly chunkSeen = new WeakMap<Session, Set<string>>() + /** Redacted records retained until {@link release}; weak keys do not extend session lifetime. */ + private readonly held = new WeakMap<Session, PendingRecord[]>() /** * @param ctx - the composing backend's context; listeners bind to its fiber. * @param backend - the backend receiving records; owned elsewhere, never disposed here beyond `shutdown()` forwarding. + * @param delivery - immediate handoff, or held delivery released explicitly per session. */ constructor( private readonly ctx: Context, private readonly backend: TelemetryBackend, + private readonly delivery: TelemetryDelivery = 'immediate', ) { ctx.on('session/created', (session) => { this.adopt(session) }) - // The session's own termination edge: emit the shutdown marker HERE — - // receivers classify a session with activity and no marker as crashed, - // so a normally closed session in a long-running host must get its - // marker at disposal, not never. Then retire: the projection/cursor - // WeakMaps die with the Session object; only the strong adopted set - // needs the explicit release. + // Capture the shutdown marker at the session's own termination edge. + // Immediate delivery preserves crash classification; held delivery does + // not let a later lifecycle edge extend a user-released prefix. Then + // retire the only strong reference owned by this coordinator. ctx.on('session/disposed', (session) => { this.contain(() => { if (!this.adopted.delete(session)) return - this.handOff(shutdownRecord(session)) + this.submit(session, { record: this.redact(shutdownRecord(session)) }) }) }) ctx.on('session/event', (session, event) => { @@ -95,13 +107,12 @@ export class TelemetryCoordinator { }) }) ctx.effect(() => async () => { - // Sessions still adopted here are alive through a whole-application - // teardown (their own disposal edge will fire after telemetry is gone, - // unobserved) — mark them now so the receiver sees a clean stop of - // observation rather than a crash-shaped silence. + // Sessions still adopted here are alive through whole-application + // teardown, so capture the marker before the backend quiesces. Held + // delivery intentionally leaves it local without another release. for (const session of this.adopted) { this.contain(() => { - this.handOff(shutdownRecord(session)) + this.submit(session, { record: this.redact(shutdownRecord(session)) }) }) } try { @@ -115,6 +126,23 @@ export class TelemetryCoordinator { } } + /** + * Hand the records currently held for one session to the backend in capture order. + * Records captured after this call form a new held prefix. Backend failures remain + * contained per record and do not starve later records in the same release. + * @param session - session whose pending capture prefix may leave the process. + */ + release(session: Session): void { + const pending = this.held.get(session) + if (pending === undefined) return + this.held.delete(session) + for (const record of pending) { + this.contain(() => { + this.deliver(session, record) + }) + } + } + /** * Adopt a session: replay its log THROUGH the projection from the handoff * cursor, then rely on the firehose for everything after. When no cursor @@ -153,7 +181,7 @@ export class TelemetryCoordinator { } } - /** Project one event and hand it to the backend, advancing the cursor on handoff. */ + /** Project and redact one event, then submit it under the delivery policy. */ private capture(session: Session, event: SessionEvent): void { if (event.type === 'assistant/chunk') { const key = `${event.data.turn}:${event.data.step}` @@ -165,27 +193,47 @@ export class TelemetryCoordinator { if (seen.has(key)) return seen.add(key) } - this.handOff({ - channel: 'ledger', - time: event.time, - severity: severityOf(event), - attributes: identityOf(session, event), - // The live event object is mutable and the backend serializes later; - // append-time validation guarantees this clone cannot throw. - body: structuredClone(event.data), + this.submit(session, { + record: this.redact({ + channel: 'ledger', + time: event.time, + severity: severityOf(event), + attributes: identityOf(session, event), + // The live event object is mutable and the backend serializes later; + // append-time validation guarantees this clone cannot throw. + body: structuredClone(event.data), + }), + seq: event.seq, }) - handoffCursor.set(session, event.seq) } /** - * Run the `telemetry/record` waterfall over one record and hand the result - * to the backend. The innermost `next` passes the record through unchanged - * — the seam ships no rules; exported data is as clean as the listeners a - * deployment mounts. Callers run inside {@link contain}, so a throwing - * rule withholds the record instead of reaching the loop (fail-closed). + * Run the `telemetry/record` waterfall at capture time. The innermost `next` + * passes the record through unchanged — the seam ships no rules; exported + * data is as clean as the listeners a deployment mounts. Callers run inside + * {@link contain}, so a throwing rule withholds the record instead of + * reaching the loop (fail-closed). Held delivery stores only this result, so + * a later policy reload cannot expose the pre-redaction capture. */ - private handOff(record: TelemetryRecord): void { - this.backend.emit(this.ctx.waterfall('telemetry/record', record, () => record)) + private redact(record: TelemetryRecord): TelemetryRecord { + return this.ctx.waterfall('telemetry/record', record, () => record) + } + + /** Hold one redacted record or deliver it immediately under the configured policy. */ + private submit(session: Session, pending: PendingRecord): void { + if (this.delivery === 'held') { + let records = this.held.get(session) + if (records === undefined) this.held.set(session, records = []) + records.push(pending) + return + } + this.deliver(session, pending) + } + + /** Hand one redacted record to the backend, then advance its ledger cursor. */ + private deliver(session: Session, pending: PendingRecord): void { + this.backend.emit(pending.record) + if (pending.seq !== undefined) handoffCursor.set(session, pending.seq) } /** Forward the turn-end boundary to the backend's optional flush hint. */ @@ -196,19 +244,21 @@ export class TelemetryCoordinator { /** Relay one `agent/error` bus emission as an `agent-error` operational record. */ private relayAgentError(agent: Agent, turn: number, step: number, error: unknown): void { const detail = errorDetail(error) - this.handOff({ - channel: 'ops', - time: Date.now(), - severity: 'error', - attributes: { - 'telemetry.op': 'agent-error', - 'session.id': String(agent.session.id), - 'agent.id': agent.id, - 'error.name': detail.name, - turn, - step, - }, - body: detail, + this.submit(agent.session, { + record: this.redact({ + channel: 'ops', + time: Date.now(), + severity: 'error', + attributes: { + 'telemetry.op': 'agent-error', + 'session.id': String(agent.session.id), + 'agent.id': agent.id, + 'error.name': detail.name, + turn, + step, + }, + body: detail, + }), }) } diff --git a/packages/telemetry/session-telemetry/src/index.ts b/packages/telemetry/session-telemetry/src/index.ts index e7340eedd5..914ef96a95 100644 --- a/packages/telemetry/session-telemetry/src/index.ts +++ b/packages/telemetry/session-telemetry/src/index.ts @@ -3,8 +3,9 @@ * * The seam owns the CAPTURE side of session-event reporting — which records * exist (the chunk projection), what they carry (the logical record), when - * they are handed over (adoption, the per-append firehose, lifecycle - * forwarding), and the HMR handoff cursor. Everything downstream of + * they are captured (adoption, the per-append firehose, lifecycle + * forwarding), immediate versus explicitly released handoff, and the HMR + * cursor. Everything downstream of * {@link Telemetry.emit} — batching, retry, queueing, loss policy — is the * reporting SDK's territory and is deliberately not modelled here. The * design and its trade-offs are pinned in @@ -94,9 +95,10 @@ export interface TelemetryBackend { /** * Hand one record to the backend's pipeline. MUST be a non-blocking * enqueue — the coordinator calls this synchronously from the - * `session/event` hot path, so anything slower than a queue push would tax - * the agent loop. Errors thrown here are contained by the coordinator and - * logged; they never reach the loop. + * `session/event` hot path, either at capture or while releasing a held + * prefix, so anything slower than a queue push would tax the agent loop. + * Errors thrown here are contained by the coordinator and logged; they + * never reach the loop. * @param record - the logical record to report; owned by the backend after the call. */ emit(record: TelemetryRecord): void @@ -121,6 +123,9 @@ export interface TelemetryBackend { * coordinator emits its dispose-time `shutdown` markers immediately before * calling this). Awaited by the coordinator's dispose; a rejection is * logged as a warning and never fails application teardown. + * The coordinator captures dispose-time shutdown markers immediately + * before this call; immediate delivery enqueues them, while held delivery + * leaves an unreleased suffix local. * @returns resolves when the backend's pipeline has quiesced. */ shutdown(): Promise<void> @@ -153,4 +158,4 @@ export abstract class Telemetry extends Service implements TelemetryBackend { abstract shutdown(): Promise<void> } -export { TelemetryCoordinator } from './coordinator.ts' +export { TelemetryCoordinator, type TelemetryDelivery } from './coordinator.ts' diff --git a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts index a449a4053d..d913e6a742 100644 --- a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts +++ b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts @@ -10,7 +10,12 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import SessionStore, { SessionId, type Session, type SessionEvent } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' -import { TelemetryCoordinator, type TelemetryBackend, type TelemetryRecord } from '../src/index.ts' +import { + TelemetryCoordinator, + type TelemetryBackend, + type TelemetryDelivery, + type TelemetryRecord, +} from '../src/index.ts' declare module '@deepseek-ai/dsh-session' { interface SessionEventMap { @@ -54,15 +59,21 @@ class FakeBackend implements TelemetryBackend { } } -async function setup(backend: FakeBackend = new FakeBackend()) { +async function setup( + backend: FakeBackend = new FakeBackend(), + delivery: TelemetryDelivery = 'immediate', +) { const ctx = new Context() await ctx.plugin(SessionStore) + let coordinator!: TelemetryCoordinator const fiber = await ctx.plugin({ name: 'fake-telemetry', inject: ['sessions'], - apply: (inner: Context) => void new TelemetryCoordinator(inner, backend), + apply: (inner: Context) => { + coordinator = new TelemetryCoordinator(inner, backend, delivery) + }, }) - return { ctx, backend, fiber } + return { ctx, backend, coordinator, fiber } } function liveSession(ctx: Context, id = `s-${Math.random().toString(36).slice(2)}`): Session { @@ -167,6 +178,80 @@ describe('TelemetryCoordinator capture', () => { }) }) +describe('TelemetryCoordinator held delivery', () => { + it('releases one pending prefix at a time without handing later records over early', async () => { + const { ctx, backend, coordinator } = await setup(new FakeBackend(), 'held') + const session = liveSession(ctx, 'held-prefix') + appendTurn(session) + expect(backend.records).toEqual([]) + + coordinator.release(session) + expect(backend.ledger().map(record => record.attributes['event.type'])).toEqual([ + 'turn/start', + 'user/message', + ]) + + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + expect(backend.ledger()).toHaveLength(2) + coordinator.release(session) + coordinator.release(session) + expect(backend.ledger().map(record => record.attributes['event.type'])).toEqual([ + 'turn/start', + 'user/message', + 'turn/end', + ]) + }) + + it('stores the capture-time redacted copy rather than re-running policy at release', async () => { + const { ctx, backend, coordinator } = await setup(new FakeBackend(), 'held') + const disposeRule = ctx.on('telemetry/record', (_record, next) => ({ + ...next(), + body: { scrubbed: true }, + })) + const session = liveSession(ctx, 'held-redacted') + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + disposeRule() + + coordinator.release(session) + expect(backend.ledger()[0]!.body).toEqual({ scrubbed: true }) + }) + + it('contains each backend failure independently while releasing a batch', async () => { + const backend = new FakeBackend() + backend.rejectSeq = 1 + const { ctx, coordinator } = await setup(backend, 'held') + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const session = liveSession(ctx, 'held-failure') + appendTurn(session) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + coordinator.release(session) + expect(backend.ledger().map(record => record.attributes['event.seq'])).toEqual([0, 2]) + expect(warn).toHaveBeenCalled() + }) + + it('rebuilds an unreleased prefix after coordinator reload', async () => { + const first = new FakeBackend() + const { ctx, fiber } = await setup(first, 'held') + const session = liveSession(ctx, 'held-reload') + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + await fiber.dispose() + expect(first.records).toEqual([]) + + const second = new FakeBackend() + let coordinator!: TelemetryCoordinator + await ctx.plugin({ + name: 'fake-telemetry-after-held-reload', + inject: ['sessions'], + apply: (inner: Context) => { + coordinator = new TelemetryCoordinator(inner, second, 'held') + }, + }) + coordinator.release(session) + expect(second.ledger().map(record => record.attributes['event.seq'])).toEqual([0]) + }) +}) + describe('TelemetryCoordinator adoption', () => { it('exports an unpublished suffix without re-exporting constructor history', async () => { const backend = new FakeBackend() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ecfd0e869..882d42c8d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -445,6 +445,9 @@ importers: '@cordisjs/plugin-include': specifier: workspace:* version: link:../vendor/include + '@cordisjs/plugin-logger-console': + specifier: workspace:* + version: link:../vendor/logger-console '@deepseek-ai/dsh-acp-demo': specifier: workspace:* version: link:../packages/examples/acp-demo @@ -466,6 +469,9 @@ importers: '@deepseek-ai/dsh-code-runtime-worker': specifier: workspace:* version: link:../packages/code-runtime/code-runtime-worker + '@deepseek-ai/dsh-command-feedback': + specifier: workspace:* + version: link:../packages/feedback/command-feedback '@deepseek-ai/dsh-compact-basic': specifier: workspace:* version: link:../packages/compact/compact-basic @@ -4810,6 +4816,9 @@ importers: '@cordisjs/plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader + '@deepseek-ai/dsh-command-feedback': + specifier: workspace:^ + version: link:../../feedback/command-feedback '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants From 9d9b547d55dc6a2db4449193bcc505e2b5282712 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Wed, 5 Aug 2026 12:47:53 +0800 Subject: [PATCH 013/104] docs: refresh telemetry module graph --- docs/module-graph.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index 61477ae104..bddaf47f98 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -630,10 +630,6 @@ flowchart TD pkg_tasks_local --> pkg_invariants pkg_tasks_local --> pkg_tasks pkg_tasks_local --> pkg_timeout - pkg_session_telemetry_otel --> pkg_invariants - pkg_session_telemetry_otel --> pkg_llm - pkg_session_telemetry_otel --> pkg_session - pkg_session_telemetry_otel --> pkg_session_telemetry pkg_agent_loop --> pkg_agent pkg_agent_loop --> pkg_invariants pkg_agent_loop --> pkg_llm @@ -813,6 +809,11 @@ flowchart TD pkg_tool_tasks --> pkg_system_prompt pkg_tool_tasks --> pkg_tasks pkg_tool_tasks --> pkg_tools + pkg_session_telemetry_otel --> pkg_command_feedback + pkg_session_telemetry_otel --> pkg_invariants + pkg_session_telemetry_otel --> pkg_llm + pkg_session_telemetry_otel --> pkg_session + pkg_session_telemetry_otel --> pkg_session_telemetry pkg_tool_workflow --> pkg_agent pkg_tool_workflow --> pkg_invariants pkg_tool_workflow --> pkg_llm @@ -1096,7 +1097,6 @@ flowchart TD | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | -| [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | @@ -1126,6 +1126,7 @@ flowchart TD | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) | | [`tool-pty`](../packages/pty/tool-pty) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`pty`](../packages/pty/pty), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | From bb920b32e004926157b3d4e842d9d419b2b1f5ad Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Thu, 6 Aug 2026 13:59:05 +0800 Subject: [PATCH 014/104] feat(web): add dedicated skill tool row --- .../2026-08-06-web-skill-tool-row.i18n.yaml | 6 + .../feature/2026-08-06-web-skill-tool-row.md | 29 +++ .../2026-08-06-web-skill-tool-row.zh.md | 29 +++ apps/web/tests/skill-tool-row.e2e.ts | 80 +++++++ .../snapshots/skill-tool-row/ui.expected.md | 48 ++++ apps/web/tsconfig.json | 1 + .../client/ui-primitives/src/icons/index.tsx | 14 ++ .../client/ui-primitives/tests/icons.spec.tsx | 4 +- packages/client/ui-skill/README.i18n.yaml | 4 +- packages/client/ui-skill/README.md | 4 + packages/client/ui-skill/README.zh.md | 4 + packages/client/ui-skill/package.json | 20 +- .../ui-skill/src/client/SkillRow.module.css | 212 ++++++++++++++++++ .../client/ui-skill/src/client/SkillRow.tsx | 174 ++++++++++++++ packages/client/ui-skill/src/client/index.ts | 26 ++- .../client/ui-skill/src/client/locales.ts | 23 ++ .../ui-skill/tests/browser-plugin.spec.ts | 69 +++++- .../client/ui-skill/tests/skill-row.spec.tsx | 152 +++++++++++++ packages/client/ui-skill/tsconfig.json | 9 + pnpm-lock.yaml | 24 ++ tsconfig.host.json | 1 + 21 files changed, 921 insertions(+), 12 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.md create mode 100644 .agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.zh.md create mode 100644 apps/web/tests/skill-tool-row.e2e.ts create mode 100644 apps/web/tests/snapshots/skill-tool-row/ui.expected.md create mode 100644 packages/client/ui-skill/src/client/SkillRow.module.css create mode 100644 packages/client/ui-skill/src/client/SkillRow.tsx create mode 100644 packages/client/ui-skill/src/client/locales.ts create mode 100644 packages/client/ui-skill/tests/skill-row.spec.tsx diff --git a/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.i18n.yaml new file mode 100644 index 0000000000..8186444a8d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.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-06-web-skill-tool-row.md +2026-08-06-web-skill-tool-row.md: b1d76c411d7ccc839616ddcce9fee18716489bf5 +2026-08-06-web-skill-tool-row.zh.md: c16a9b84d75c641b0fdd8778ff56c331c2c81546 diff --git a/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.md b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.md new file mode 100644 index 0000000000..b1d76c411d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.md @@ -0,0 +1,29 @@ +# Agent Note: Web skill tool row + +Status: implemented + +English | [中文](2026-08-06-web-skill-tool-row.zh.md) + +## Problem + +The Web transcript renders `skill` calls through the generic fallback row, so a loaded instruction set looks like an unknown tool call even though Skill is a first-class product concept. The generic row also exposes the JSON argument envelope beside the result, adding noise around the one identity users need: the loaded skill name. + +## Decision + +`ui-skill` registers a component under the existing `conversation.chat.toolview` keyed slot with key `skill`. The component owns its row chrome from the public `ToolRowProps` contract, matching the independent registrant posture used by the Bash sample instead of importing conversation-private components. + +The collapsed row uses a 16-pixel document-and-sparkle glyph and the Bash row's neutral hierarchy: tertiary glyph, secondary `Skill` title, caption separator, and tertiary skill name. Running, failed, and interrupted calls retain the transcript's shimmer, error dot and first-line summary, and warning dot semantics. A settled call expands through the whole summary row into a 260-pixel bounded `Instructions` card containing the exact durable result text; the existing trajectory `Inspect` handoff remains available below the card. + +The row derives every visible value from the logged call/result slice. It reads the skill name from the recorded `name` argument and the instructions from durable result content, and never joins the current skill catalog for descriptions or provider metadata. The existing ACP `skill-load` recording is seeded through the real Web persistence and composition path for a keyless interaction and accessibility snapshot. + +## Alternatives considered + +- Keep the generic tool row and add only a `skill` color selector in `ui-conversation`. This leaves the redundant input envelope and generic expanded body in place, and makes the conversation package own a domain-specific visual rule. +- Add a new `skill` value to the host tool render-intent union. The keyed client slot already identifies this tool without changing the wire contract, so a new cross-boundary presentation value adds protocol and snapshot surface without enabling another consumer. +- Export the conversation package's private `ToolRow` component for reuse. Client packages intentionally expose contracts rather than cross-package components; exporting it would couple independent feature packages to conversation implementation details. + +## Consequences + +`ui-skill` now depends on the public conversation toolview contract, locale and primitive packages, and React in addition to its reference-source dependencies. It owns a small copy of the disclosure-row chrome, so future global interaction changes must update this registrant alongside the Bash sample and conversation rows. + +Cold replay stays deterministic when the installed skill catalog changes, and the transcript remains compact until instructions are explicitly expanded. The dedicated card intentionally shows the tool's complete framed output rather than extracting only `<skill_instructions>`, preserving exactly what reached the model and avoiding a second parser for the skill result format. diff --git a/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.zh.md b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.zh.md new file mode 100644 index 0000000000..c16a9b84d7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.zh.md @@ -0,0 +1,29 @@ +# Agent Note: Web skill 工具行 + +Status: implemented + +[English](2026-08-06-web-skill-tool-row.md) | 中文 + +## 问题 + +Web transcript(文本记录)通过通用后备行渲染 `skill` 调用,使已加载的指令集看起来像一次未知工具调用,尽管 Skill(技能)已是产品中的一等概念。通用行还会在结果旁暴露 JSON 参数的外层结构,围绕用户真正需要的唯一标识增加了噪声:已加载的 skill 名称。 + +## 决策 + +`ui-skill` 在现有的 `conversation.chat.toolview` 键控 slot 下注册 key 为 `skill` 的组件。该组件基于公开的 `ToolRowProps` 契约自行实现行 chrome,沿用 Bash 示例的独立注册方姿态,而不导入 conversation 私有组件。 + +收起的行使用 16 像素的文档与闪光组合图标,并沿用 Bash 行的中性色层级:图标采用三级色,`Skill` 标题采用二级色,分隔符采用 caption 色,skill 名称采用三级色。运行、失败和中断调用分别沿用 transcript 的扫光、错误状态点加首行摘要,以及警告状态点语义。已结算调用可以通过整个摘要行展开一个高度上限为 260 像素的 `Instructions` 卡片,其中原样呈现持久化结果文本;用于跳转至 trajectory 的现有 `Inspect` 入口仍保留在卡片下方。 + +该行的所有可见值均派生自已记录的调用/结果片段。skill 名称来自已记录的 `name` 参数,指令来自持久化的结果内容;该行绝不关联当前 skill 目录来读取描述或提供方元数据。现有的 ACP(Agent Client Protocol)`skill-load` 记录经由真实的 Web 持久化与组合路径写入,用于无需密钥的交互和无障碍快照。 + +## 考虑过的替代方案 + +- 保留通用工具行,只添加一个 `skill` 颜色选择器,并将其放在 `ui-conversation` 中。该方案仍会保留多余的输入外层结构和通用展开体,也会让 conversation 包拥有特定领域的视觉规则。 +- 在宿主工具渲染意图联合类型中添加新的 `skill` 值。键控客户端 slot 无需更改协议契约即可识别该工具,因此新的跨边界呈现值只会增加协议与快照表层,却没有为其他消费方提供新能力。 +- 导出 conversation 包的私有 `ToolRow` 组件供复用。客户端包刻意对外暴露契约而非跨包组件;导出该组件会使独立功能包耦合到 conversation 的实现细节。 + +## 后果 + +除了引用 source 的依赖外,`ui-skill` 现在还依赖公开的 conversation toolview 契约、locale 包、原语包和 React。它自行保留了一小份折叠展开行 chrome,因此未来的全局交互变更必须与 Bash 示例和 conversation 行同步更新这个注册方。 + +即使已安装的 skill 目录发生变化,冷回放仍具有确定性;在用户显式展开指令前,transcript 保持紧凑。专用卡片有意显示工具完整封装的输出,而不是只提取 `<skill_instructions>`,从而原样保留模型实际收到的内容,也避免为 skill 结果格式再引入一个解析器。 diff --git a/apps/web/tests/skill-tool-row.e2e.ts b/apps/web/tests/skill-tool-row.e2e.ts new file mode 100644 index 0000000000..af6c941bcd --- /dev/null +++ b/apps/web/tests/skill-tool-row.e2e.ts @@ -0,0 +1,80 @@ +// Web e2e scenario: the real skill-load recording, seeded cold through the +// persistence seam, renders through ui-skill's keyed toolview without a model +// call. The disclosure proves replay-stable naming and exact durable output. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +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, seedSession, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const FIXTURE = fileURLToPath(new URL('../../../examples/acp-agent/tests/snapshots/skill-load/session.jsonl', import.meta.url)) +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/skill-tool-row', import.meta.url)) +const UI_EXPECTED = fileURLToPath(new URL('./snapshots/skill-tool-row/ui.expected.md', import.meta.url)) +const MODE = webSnapshotMode() +const SEED_ID = 'skill-tool-row-web-e2e' +const PROMPT = 'Load the snapshot-skill skill with the skill tool, then reply DONE.' + +describe.skipIf(MODE === 'record')('web e2e: dedicated Skill tool row', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType<typeof watchConsole> + + beforeAll(async () => { + const fixture = await readFile(FIXTURE, 'utf8') + expect(fixtureUserPrompts(fixture)).toEqual([PROMPT]) + scaffold = await launchWebScaffold({}) + await seedSession(scaffold, fixture, SEED_ID) + 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 }) + + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + await page.locator('[data-tool="skill"]').waitFor({ timeout: 15_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('expands the loaded skill to its exact recorded instructions', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-skill-tool-row')) + const call = page.locator('[data-tool="skill"]') + const row = call.getByRole('button', { name: 'Skill snapshot-skill' }) + await expect.poll(() => row.getAttribute('aria-expanded')).toBe('false') + expect(await call.getByText('snapshot-skill', { exact: true }).count()).toBe(1) + + await row.click() + await expect.poll(() => row.getAttribute('aria-expanded')).toBe('true') + await call.getByText('Instructions', { exact: true }).waitFor() + const output = call.locator('pre') + await output.waitFor() + expect(await output.textContent()).toContain('<skill_content name="snapshot-skill">') + expect(await output.textContent()).toContain('Follow these snapshot-only instructions.') + expect(await output.evaluate(element => getComputedStyle(element.parentElement!).maxHeight)).toBe('260px') + + const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) + .replace(/\b\d{1,2}\/\d{1,2}(?= \{\{clock\}\})/g, '{{date}}') + .split(SEED_ID).join('{{seededId}}') + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 60_000) + + it('keeps its snapshot inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + }) +}) diff --git a/apps/web/tests/snapshots/skill-tool-row/ui.expected.md b/apps/web/tests/snapshots/skill-tool-row/ui.expected.md new file mode 100644 index 0000000000..7a51aae904 --- /dev/null +++ b/apps/web/tests/snapshots/skill-tool-row/ui.expected.md @@ -0,0 +1,48 @@ +- banner: + - navigation "Session hierarchy": + - button "Load the snapshot-skill skill with" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: Load the snapshot-skill skill with the skill tool, then reply DONE. {{date}} {{clock}} +- button "Copy": + - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn +- button "Context injection @deepseek-ai/dsh-system-prompt": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- button "Context injection skill-catalog": + - img + - img + - text: Context injection skill-catalog +- button "Think Load the requested skill.": + - img + - img + - text: Think Load the requested skill. +- button "Skill snapshot-skill" [expanded]: + - img + - text: Skill snapshot-skill +- region "Instructions": "Instructions <skill_content name=\"snapshot-skill\"> <skill_resources> Base directory for this skill: {{cwd}}/.dsh/skills/snapshot-skill Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed. </skill_resources> <skill_instructions> Follow these snapshot-only instructions. Resolve referenced resources relative to this skill directory. </skill_instructions> </skill_content>" +- button "Inspect" +- button "Think The skill is loaded.": + - img + - img + - text: Think The skill is loaded. +- paragraph: DONE +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: {{date}} {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- textbox "Message the agent" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model": + - text: Select model + - img +- button "Send message" [disabled] +- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 280 tok · Output 30 tok diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index dd5fe879e7..9e395e49ad 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -62,6 +62,7 @@ "tests/startup-auto-selection.e2e.ts", "tests/subagent-conversation.e2e.ts", "tests/bash-abort-row.e2e.ts", + "tests/skill-tool-row.e2e.ts", "tests/chat-scroll-fixture.ts", "tests/chat-scroll-contract.e2e.ts", "tests/chat-long-interactions.e2e.ts", diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 71e647a3e9..b0b76e164b 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -750,6 +750,20 @@ export const IconSparkle16 = ({ size = 16, className }: IconProps) => ( </svg> ) +/** skill_outline_16 (skill tool-row glyph; document instructions + sparkle) */ +export const IconSkillOutline16 = ({ size = 16, className }: IconProps) => ( + <svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path + d="M12.5113 15.4067C12.4395 15.6249 12.1308 15.6249 12.059 15.4067L11.643 14.1416C11.454 13.567 11.0033 13.1164 10.4288 12.9274L9.16369 12.5113C8.94544 12.4395 8.94544 12.1308 9.16369 12.059L10.4288 11.643C11.0033 11.454 11.454 11.0033 11.643 10.4288L12.059 9.16369C12.1308 8.94544 12.4395 8.94544 12.5113 9.16369L12.9274 10.4288C13.1164 11.0033 13.567 11.454 14.1416 11.643L15.4067 12.059C15.6249 12.1308 15.6249 12.4395 15.4067 12.5113L14.1416 12.9274C13.567 13.1164 13.1164 13.567 12.9274 14.1416L12.5113 15.4067Z" + fill="currentColor" + /> + <path + d="M9.02246 0.546878C9.9822 0.546878 10.7564 0.545403 11.374 0.612307C12.0042 0.680586 12.5515 0.826244 13.0273 1.17188C13.3052 1.37376 13.5501 1.61868 13.752 1.89649C14.0975 2.37225 14.2432 2.91984 14.3115 3.54981C14.3784 4.16727 14.377 4.94206 14.377 5.90137V8.51367C13.9611 8.29533 13.5071 8.13985 13.0273 8.06055V5.90137C13.0273 4.9121 13.0259 4.22322 12.9688 3.69532C12.9129 3.18044 12.8098 2.89782 12.6592 2.69043C12.5406 2.52724 12.3966 2.38326 12.2334 2.26465C12.026 2.11404 11.7437 2.0109 11.2285 1.95508C10.7005 1.89789 10.0122 1.89649 9.02246 1.89649H6.55371C5.56395 1.89649 4.87569 1.89787 4.34766 1.95508C3.83242 2.01092 3.55022 2.11398 3.34278 2.26465C3.17953 2.38329 3.03564 2.52719 2.91699 2.69043C2.76642 2.89782 2.66325 3.18042 2.60742 3.69532C2.55027 4.22322 2.54883 4.9121 2.54883 5.90137V10.0986C2.54883 11.0878 2.55031 11.7768 2.60742 12.3047C2.66326 12.8196 2.76642 13.1032 2.91699 13.3105C3.03558 13.4736 3.17966 13.6178 3.34278 13.7363C3.5502 13.8869 3.83265 13.9901 4.34766 14.0459C4.87568 14.1031 5.56398 14.1035 6.55371 14.1035H8.08399C8.27443 14.6025 8.55077 15.0585 8.89551 15.4541H6.55371C5.59402 15.4541 4.81976 15.4546 4.20215 15.3877C3.57204 15.3194 3.02468 15.1738 2.54883 14.8281C2.27111 14.6263 2.02606 14.3813 1.82422 14.1035C1.47883 13.6278 1.33293 13.08 1.26465 12.4502C1.19783 11.8327 1.19922 11.0579 1.19922 10.0986V5.90137C1.19922 4.94206 1.1978 4.16727 1.26465 3.54981C1.33295 2.91984 1.47867 2.37225 1.82422 1.89649C2.02613 1.61864 2.27098 1.37379 2.54883 1.17188C3.02472 0.826181 3.57197 0.6806 4.20215 0.612307C4.81976 0.545393 5.594 0.546877 6.55371 0.546878H9.02246ZM9.19629 9.14649H4.5459V7.84571H9.19629V9.14649ZM11.0303 6.10645H4.5459V4.80567H11.0303V6.10645Z" + fill="currentColor" + /> + </svg> +) + /** ic_ds_question_outline_14 (figma extract): ring + question glyph. */ export const IconQuestionOutline14 = ({ size = 14, className }: IconProps) => ( <svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg"> diff --git a/packages/client/ui-primitives/tests/icons.spec.tsx b/packages/client/ui-primitives/tests/icons.spec.tsx index 9877b7df1f..92f0d3cc37 100644 --- a/packages/client/ui-primitives/tests/icons.spec.tsx +++ b/packages/client/ui-primitives/tests/icons.spec.tsx @@ -16,8 +16,8 @@ const icons = Object.fromEntries( const iconNames = Object.keys(icons) describe('ic_ds_ icon set', () => { - it('exports the full P-I set (46 deepsuite + 17 figma extracts + the hand-authored sparkle)', () => { - expect(iconNames.length).toBe(64) + it('exports the full P-I set (46 deepsuite + 17 figma extracts + two hand-authored product glyphs)', () => { + expect(iconNames.length).toBe(65) }) it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => { diff --git a/packages/client/ui-skill/README.i18n.yaml b/packages/client/ui-skill/README.i18n.yaml index 059a5d8986..d23f68ee85 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: fc83ae47dc83e72d60f382892aa678989902d217 -README.zh.md: e103db812d2a21f7f211bc843ec0cd31d1dc2c1e +README.md: 2280c9302dbc46cff723752f88c47940f98417d5 +README.zh.md: 0e9344ff63139f77461b02b48e18b0e94e54c223 diff --git a/packages/client/ui-skill/README.md b/packages/client/ui-skill/README.md index fc83ae47dc..2280c9302d 100644 --- a/packages/client/ui-skill/README.md +++ b/packages/client/ui-skill/README.md @@ -8,6 +8,10 @@ A failed `skill.list` throws from `candidates`, which the slash shell logs and f The `/client` export surface is the plugin body (`apply`/`inject`) only; the source object is internal to the registration effect. +## Skill tool row + +The browser plugin also registers a keyed `skill` toolview in `conversation.chat.toolview`. A collapsed row renders the 16-pixel skill document-and-sparkle glyph, `Skill` title, separator, and requested skill name with the same neutral hierarchy as the Bash row; running calls carry the transcript shimmer, failures replace the name with the first error line, and interrupted calls use the warning state. A settled row expands as one whole-row disclosure into a bounded `Instructions` card containing the exact durable tool output, with the standard trajectory `Inspect` affordance when available. The row derives its name, lifecycle, and body only from the logged call/result slice, never from the current catalog, so cold replay remains stable even when installed skills or their descriptions change. + ## Model Experience ### Skill reference text in the user prompt diff --git a/packages/client/ui-skill/README.zh.md b/packages/client/ui-skill/README.zh.md index e103db812d..0e9344ff63 100644 --- a/packages/client/ui-skill/README.zh.md +++ b/packages/client/ui-skill/README.zh.md @@ -8,6 +8,10 @@ skill(技能)引用 source 的浏览器端:把 `/` 触发的 `skill` sourc `/client` 导出表层只有插件主体(`apply`/`inject`);source 对象是注册 effect 的内部实现。 +## skill 工具行 + +浏览器插件还会把一个 key 为 `skill` 的 toolview 注册进 `conversation.chat.toolview`。收起的行以与 Bash 行相同的中性色层级显示 16 像素的 skill 文档与闪光组合图标、`Skill` 标题、分隔符和请求加载的 skill 名称;运行中的调用带有 transcript(文本记录)的扫光效果,失败时用错误首行替换名称,中断调用则使用警告状态。已结算的行以整行作为展开入口,展开后显示一个尺寸受限的 `Instructions` 卡片,其中原样呈现持久化的工具输出;可用时还会提供标准执行轨迹的 `Inspect` 入口。该行的名称、生命周期和正文只派生自已记录的调用/结果片段,绝不读取当前 skill 目录,因此即使已安装的 skill 或其描述发生变化,冷回放仍保持稳定。 + ## 模型体验 ### 用户提示词中的 skill 引用文本 diff --git a/packages/client/ui-skill/package.json b/packages/client/ui-skill/package.json index 20d61cdb53..c9d2dd4ed8 100644 --- a/packages/client/ui-skill/package.json +++ b/packages/client/ui-skill/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-skill", - "description": "Skill reference source: '/' menu candidates from skill.list, inserts <skill>name</skill> references", + "description": "Web skill references and the dedicated skill tool row", "version": "0.0.1", "private": true, "type": "module", @@ -25,6 +25,8 @@ "dshClient": { "inject": [ "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-conversation", "@deepseek-ai/dsh-client-ui-slash" ], "platform": "web" @@ -36,19 +38,31 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-client-connection": "^0.0.1", + "@deepseek-ai/dsh-client-locale": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", + "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1", + "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", "@deepseek-ai/dsh-client-ui-slash": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" }, "devDependencies": { "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slash": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@testing-library/react": "^16.1.0", + "@types/react": "~18.3.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0", + "react-dom": "^18.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-skill/src/client/SkillRow.module.css b/packages/client/ui-skill/src/client/SkillRow.module.css new file mode 100644 index 0000000000..05c3388aa9 --- /dev/null +++ b/packages/client/ui-skill/src/client/SkillRow.module.css @@ -0,0 +1,212 @@ +/* Skill toolview: Bash-matched summary row plus a bounded instructions disclosure. */ + +.card { + display: flex; + flex-direction: column; +} + +.row { + position: relative; + overflow: hidden; + display: flex; + align-items: center; + height: 24px; + min-width: 0; +} + +.row[data-expandable] { + cursor: pointer; +} + +.card[data-state='running'] .row::after { + content: ''; + position: absolute; + inset: 0 auto 0 0; + width: 300px; + background: linear-gradient( + 90deg, + transparent 0%, + color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%, + transparent 100% + ); + animation: dsh-skill-row-sweep 2.6s ease-out infinite; + pointer-events: none; +} + +@keyframes dsh-skill-row-sweep { + 0% { left: -300px; } + 90%, 100% { left: 100%; } +} + +.leading { + position: relative; + flex: none; + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + margin-right: 6px; + color: var(--dsw-alias-label-tertiary); +} + +.chevron { + color: var(--dsw-alias-label-secondary); +} + +.iconIdle { + display: inline-flex; + opacity: 1; + transition: opacity 100ms ease; +} + +.chevronHover { + position: absolute; + inset: 0; + margin: auto; + opacity: 0; + transition: opacity 100ms ease; +} + +.row:hover .iconIdle { + opacity: 0; +} + +.row:hover .chevronHover { + opacity: 1; +} + +.title { + flex: none; + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-secondary); +} + +.separator { + flex: none; + width: 2px; + height: 2px; + border-radius: 1px; + margin: 0 8px; + background: var(--dsw-alias-label-caption); +} + +.summary { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-tertiary); +} + +.errorSummary { + color: var(--dsw-alias-state-error-primary); +} + +.bodyWrap { + display: flex; + flex-direction: column; +} + +.instructionsCard { + display: flex; + flex-direction: column; + max-height: 260px; + margin: 4px 0 4px 4px; + overflow: hidden; + border: 1px solid var(--dsw-alias-border-l1); + border-radius: 12px; + background: var(--dsw-alias-markdown-code-block); +} + +.instructionsHeader { + flex: none; + padding: 8px 12px; + border-bottom: 1px solid var(--dsw-alias-border-l2); + background: var(--dsw-alias-markdown-code-block-banner); + font-size: 11px; + font-weight: 500; + line-height: 16px; + color: var(--dsw-alias-label-caption); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.instructions { + min-height: 0; + margin: 0; + padding: 10px 12px 12px; + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; + font: var(--dsw-font-markdown-code-block-small); + color: var(--dsw-alias-label-secondary); +} + +.instructions[data-error] { + color: var(--dsw-alias-state-error-primary); +} + +.instructions::-webkit-scrollbar-thumb { + border: 2px solid transparent; + background-clip: padding-box; + border-radius: 6px; +} + +.instructions::-webkit-scrollbar-track { + margin: 6px 0; +} + +.inspectButton { + display: inline-flex; + align-self: flex-start; + align-items: center; + gap: 4px; + margin: 4px 0 2px 4px; + padding: 2px 8px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 999px; + background: var(--dsw-alias-bg-base); + color: var(--dsw-alias-label-secondary); + font-size: 11px; + line-height: 16px; + cursor: pointer; + opacity: 0; + transition: opacity 100ms ease; +} + +.card:hover .inspectButton, +.inspectButton:focus-visible { + opacity: 1; +} + +.inspectButton:hover { + background: var(--dsw-alias-interactive-bg-hover-solid); + color: var(--dsw-alias-label-primary); +} + +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + +@media (prefers-reduced-motion: reduce) { + .card[data-state='running'] .row::after { + animation: none; + display: none; + } + + .iconIdle, + .chevronHover, + .inspectButton { + transition: none; + } +} diff --git a/packages/client/ui-skill/src/client/SkillRow.tsx b/packages/client/ui-skill/src/client/SkillRow.tsx new file mode 100644 index 0000000000..c847678e4a --- /dev/null +++ b/packages/client/ui-skill/src/client/SkillRow.tsx @@ -0,0 +1,174 @@ +// Skill toolview registrant: a domain-owned row over the keyed toolview hole. +// The compact accent row keeps loaded instructions scannable in the transcript; +// the exact durable tool output remains available in a bounded disclosure card. + +import { useState, type KeyboardEvent, type ReactNode } from 'react' +import { + IconChevronDownOutline14, IconSkillOutline16, StateDot, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import css from './SkillRow.module.css' + +/** Skill row lifecycle derived solely from the durable call slice. */ +type SkillRowState = 'running' | 'ok' | 'error' | 'stopped' + +/** Full row props: the toolview runtime share plus this package's locale seat. */ +type SkillRowProps = ToolRowProps & PropsLocale<'skill'> + +/** Compact, replay-stable view model for the dedicated row. */ +interface SkillRowModel { + readonly name: string + readonly output: string | null + readonly errorSummary: string | null + readonly state: SkillRowState +} + +/** First physical line for the collapsed error summary and malformed-args fallback. */ +function firstLine(text: string): string { + const newline = text.indexOf('\n') + return newline === -1 ? text : text.slice(0, newline) +} + +/** Skill names are the only call argument the compact row presents. */ +function skillName(argsRaw: string, callId: string): string { + try { + const parsed = JSON.parse(argsRaw) as unknown + if (typeof parsed === 'object' && parsed !== null) { + const name = (parsed as Record<string, unknown>).name + if (typeof name === 'string' && name !== '') return firstLine(name) + } + } catch { + // Streaming can expose a truncated JSON prefix; its first line is still + // more useful than replacing the call with an unrelated catalog lookup. + } + return argsRaw === '' ? callId : firstLine(argsRaw) +} + +/** Flatten the durable result exactly like the generic row's text fallback. */ +function resultText(block: ToolRowProps['block']): string | null { + if (!('kind' in block)) return null + const parts: string[] = [] + for (const item of block.content) { + parts.push(item.type === 'text' ? item.text : JSON.stringify(item, null, 2)) + } + if (parts.length === 0 && block.error !== undefined) { + parts.push(`${block.error.name}: ${block.error.code}`) + } + return parts.join('\n') || null +} + +/** Derive display state without consulting the live skill catalog. */ +function skillRowModel(block: ToolRowProps['block']): SkillRowModel { + const settled = 'kind' in block + const argsRaw = (settled ? block.call?.argsRaw : block.argsRaw) ?? '' + const state: SkillRowState = !settled + ? 'running' + : block.error?.code === 'interrupted' + ? 'stopped' + : block.isError ? 'error' : 'ok' + const output = resultText(block) + return { + name: skillName(argsRaw, block.callId), + output, + errorSummary: state === 'error' && output !== null ? firstLine(output) : null, + state, + } +} + +/** State substitution for the collapsed leading slot. */ +function leadingFor(state: SkillRowState): ReactNode { + switch (state) { + case 'error': return <StateDot state="error" /> + case 'stopped': return <StateDot state="warning" /> + default: return <IconSkillOutline16 /> + } +} + +/** Visually hidden state copy for the colour-only lifecycle cues. */ +function stateStatus(state: SkillRowState, t: SkillRowProps['t']): string | null { + switch (state) { + case 'running': return t('row.running') + case 'error': return t('row.failed') + case 'stopped': return t('row.stopped') + default: return null + } +} + +/** Inspect affordance glyph shared with the transcript's other tool rows. */ +function IconInspect() { + return ( + <svg width="12" height="12" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden> + <path d="M16 8L10.8571 12V10.552L14.1383 8L10.8571 5.448V4L16 8ZM5.14286 10.552L1.86171 8L5.14286 5.448V4L0 8L5.14286 12V10.552ZM9.02514 4L5.59657 12H6.84057L10.2691 4H9.02514Z" fill="currentColor" /> + </svg> + ) +} + +/** + * Render one `skill` tool call as an accent summary and instructions disclosure. + * @param props - keyed toolview payload plus the skill locale seat. + * @returns the dedicated skill row. + */ +export function SkillRow({ block, inspect, t }: SkillRowProps) { + const model = skillRowModel(block) + const [expanded, setExpanded] = useState(false) + const expandable = model.output !== null + const open = expanded && expandable + const status = stateStatus(model.state, t) + const summary = model.errorSummary ?? model.name + const ariaLabel = status === null ? `Skill ${summary}` : `${status} Skill ${summary}` + const toggleExpand = (): void => { + setExpanded(value => !value) + } + const toggleFromKeyboard = (event: KeyboardEvent<HTMLDivElement>): void => { + if (!expandable || (event.key !== 'Enter' && event.key !== ' ')) return + event.preventDefault() + toggleExpand() + } + const leading = open + ? <IconChevronDownOutline14 className={css.chevron} /> + : expandable + ? ( + <> + <span className={css.iconIdle}>{leadingFor(model.state)}</span> + <IconChevronDownOutline14 className={`${css.chevron} ${css.chevronHover}`} /> + </> + ) + : leadingFor(model.state) + return ( + <div className={css.card} data-tool="skill" data-state={model.state}> + <div + className={css.row} + data-expandable={expandable || undefined} + role={expandable ? 'button' : undefined} + tabIndex={expandable ? 0 : undefined} + aria-expanded={expandable ? open : undefined} + aria-label={expandable ? ariaLabel : undefined} + onClick={expandable ? toggleExpand : undefined} + onKeyDown={expandable ? toggleFromKeyboard : undefined} + > + <span className={css.leading}>{leading}</span> + {status !== null ? <span className={css.visuallyHidden}>{status}</span> : null} + <span className={css.title}>Skill</span> + <span className={css.separator} aria-hidden /> + <span className={model.errorSummary === null ? css.summary : `${css.summary} ${css.errorSummary}`}> + {summary} + </span> + </div> + {open ? ( + <div className={css.bodyWrap}> + <section className={css.instructionsCard} aria-label={t('row.instructions')}> + <div className={css.instructionsHeader}>{t('row.instructions')}</div> + <pre className={css.instructions} data-error={model.state === 'error' || undefined}>{model.output}</pre> + </section> + {inspect !== undefined ? ( + <button type="button" className={css.inspectButton} onClick={inspect}> + <IconInspect /> + Inspect + </button> + ) : null} + </div> + ) : null} + </div> + ) +} diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index c23f15b770..9631125801 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -19,10 +19,24 @@ * not kill the prewarm other consumers will hit, so it carries its own * abort (fired only on invalidation/teardown) while a candidates caller * with an aborted signal just returns early. + * + * This browser half also owns the `skill` keyed toolview: a replay-stable + * accent row derived only from each logged call/result slice. */ import type { ConnectionHandle, SessionId, SkillEntry } from '@deepseek-ai/dsh-client-connection/client' import type { ClientContext, ISessions } from '@deepseek-ai/dsh-client-runtime/client' import type { SlashServiceContract, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client' +// Type-only: pulls the locale plugin's Context merge (ctx.locale). +import type {} from '@deepseek-ai/dsh-client-locale/client' +import { SkillRow } from './SkillRow.tsx' +import { en, NS, zh, type SkillKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** The dedicated skill tool row's copy. */ + skill: SkillKey + } +} /** One session's catalog fetch: the shared promise plus its own abort handle. */ interface CatalogFetch { @@ -32,14 +46,20 @@ interface CatalogFetch { settled?: readonly SkillEntry[] } -/** Required services: slash registry, routed sessions, and the wire face. */ -export const inject = ['slash', 'connection', 'sessions'] +/** Required services: reference source faces plus the tool-row and locale registries. */ +export const inject = ['slash', 'connection', 'sessions', 'slots', 'locale'] /** - * Client plugin body: register the '/' skill source over the root wire face. + * Client plugin body: register the '/' source, dictionaries, and keyed tool row. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-skill: dictionaries') + ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register( + { name: 'conversation.chat.toolview', key: 'skill', locale: NS }, + SkillRow, + )) + const skills = (ctx.get('connection') as ConnectionHandle).api.skills const sessions = ctx.get('sessions') as ISessions // Session-keyed catalog cache; single-flight per key. Plugin-closure state: diff --git a/packages/client/ui-skill/src/client/locales.ts b/packages/client/ui-skill/src/client/locales.ts new file mode 100644 index 0000000000..53746397bc --- /dev/null +++ b/packages/client/ui-skill/src/client/locales.ts @@ -0,0 +1,23 @@ +/** `skill` namespace dictionaries for the dedicated tool row. */ + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'skill' + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'row.running': '正在加载 skill', + 'row.failed': 'skill 加载失败', + 'row.stopped': 'skill 加载已中止', + 'row.instructions': '说明', +} satisfies Record<string, string> + +/** The skill namespace key union. */ +export type SkillKey = keyof typeof zh + +/** English dictionary, checked complete against the zh key set. */ +export const en = { + 'row.running': 'Loading skill', + 'row.failed': 'Skill load failed', + 'row.stopped': 'Skill load stopped', + 'row.instructions': 'Instructions', +} satisfies Record<SkillKey, string> diff --git a/packages/client/ui-skill/tests/browser-plugin.spec.ts b/packages/client/ui-skill/tests/browser-plugin.spec.ts index 72a7d6f7a6..3febb36efb 100644 --- a/packages/client/ui-skill/tests/browser-plugin.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.spec.ts @@ -1,5 +1,6 @@ /** - * ui-skill browser half: source registration (duplicate-name proof) + + * ui-skill browser half: source and keyed toolview registration + + * locale dictionaries + source duplicate-name proof + * fiber-teardown removal (HMR safety) against the real SlashService, then * the source behavior contract driven directly on the captured source with * real ClientSessionContext projections — sessionId addressing, the @@ -16,6 +17,7 @@ import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client' import type { ClientSessionContext, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client' import { apply, inject } from '../src/client/index.ts' +import { SkillRow as SkillToolRow } from '../src/client/SkillRow.tsx' type SkillRow = { name: string; description: string; whenToUse?: string } type ListResult = @@ -23,6 +25,38 @@ type ListResult = | { ok: false; error: { code: string; message: string; details: object } } type ListFn = (payload: object, signal?: AbortSignal) => Promise<{ result: ListResult }> +interface PresentationRegistration { + name: string + key?: string + locale?: string +} + +interface PresentationCapture { + registration?: PresentationRegistration + component?: unknown + dictionaries: Array<{ namespace: string; dictionaries: unknown }> +} + +/** Provide the presentation registries and capture the plugin's registrations. */ +function providePresentation(ctx: Context): PresentationCapture { + const capture: PresentationCapture = { dictionaries: [] } + ctx.provide('locale', { + register(namespace: string, dictionaries: unknown) { + capture.dictionaries.push({ namespace, dictionaries }) + return () => {} + }, + }) + ctx.provide('slots', { + inject(_name: string, factory: () => unknown) { factory() }, + register(registration: PresentationRegistration, component: unknown) { + capture.registration = registration + capture.component = component + return () => {} + }, + }) + return capture +} + /** Boot the plugin over fake slash/connection faces; returns the captured source and its ctx. */ async function bench(list: ListFn, addressed?: SessionId) { const ctx = new Context() @@ -34,6 +68,7 @@ async function bench(list: ListFn, addressed?: SessionId) { ? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const } : undefined, }) + providePresentation(ctx) await ctx.plugin({ inject: [...inject], apply }).await() return { ctx, source: captured! } } @@ -65,7 +100,36 @@ const req = (query: string, signal?: AbortSignal) => describe('apply', () => { it('declares the services it binds', () => { - expect(inject).toEqual(['slash', 'connection', 'sessions']) + expect(inject).toEqual(['slash', 'connection', 'sessions', 'slots', 'locale']) + }) + + it('registers the dedicated skill row and its locale dictionaries', async () => { + const ctx = new Context() + ctx.provide('slash', { registerSource: () => () => {} }) + ctx.provide('connection', { api: { skills: { list: listOk(CATALOG) } } }) + ctx.provide('sessions', { subagentAddress: () => undefined }) + const presentation = providePresentation(ctx) + await ctx.plugin({ inject: [...inject], apply }).await() + expect(presentation.registration).toEqual({ + name: 'conversation.chat.toolview', key: 'skill', locale: 'skill', + }) + expect(presentation.component).toBe(SkillToolRow) + expect(presentation.dictionaries).toEqual([{ + namespace: 'skill', dictionaries: { + zh: { + 'row.running': '正在加载 skill', + 'row.failed': 'skill 加载失败', + 'row.stopped': 'skill 加载已中止', + 'row.instructions': '说明', + }, + en: { + 'row.running': 'Loading skill', + 'row.failed': 'Skill load failed', + 'row.stopped': 'Skill load stopped', + 'row.instructions': 'Instructions', + }, + }, + }]) }) it('registers the "/" skill source; disposal frees the name (HMR safety)', async () => { @@ -74,6 +138,7 @@ describe('apply', () => { ctx.provide('sessions', {}) await ctx.plugin(SlashService).await() ctx.provide('connection', { api: { skills: { list: listOk(CATALOG) } } }) + providePresentation(ctx) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() const slash = ctx.get('slash') as SlashService diff --git a/packages/client/ui-skill/tests/skill-row.spec.tsx b/packages/client/ui-skill/tests/skill-row.spec.tsx new file mode 100644 index 0000000000..2dacf0a036 --- /dev/null +++ b/packages/client/ui-skill/tests/skill-row.spec.tsx @@ -0,0 +1,152 @@ +// @vitest-environment jsdom +// Dedicated skill tool row: replay-stable naming, lifecycle states, disclosure, +// keyboard operation, exact output, and the trajectory Inspect handoff. + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' +import { SkillRow } from '../src/client/SkillRow.tsx' +import { zh } from '../src/client/locales.ts' + +type SkillRowProps = Parameters<typeof SkillRow>[0] + +const t: SkillRowProps['t'] = makeTranslate(zh, commonZh) + +afterEach(cleanup) + +function settled(over: Partial<ToolResultNode> = {}): ToolResultNode { + return { + kind: 'tool-result', + seq: 3, + time: 3_000, + callId: 'call-skill', + call: { name: 'skill', argsRaw: '{"name":"dsh-manage-issues"}' }, + callTime: 2_000, + content: [{ type: 'text', text: 'Follow the issue workflow.\nKeep project fields in sync.' }], + isError: false, + callView: null, + resultView: null, + ...over, + } +} + +function running(argsRaw = '{"name":"dsh-manage-issues"}'): RunningToolCall { + return { + callId: 'call-skill', name: 'skill', argsRaw, turn: 1, step: 1, time: 2_000, callView: null, + } +} + +function props(block: SkillRowProps['block'], inspect?: () => void): SkillRowProps { + return { + callId: block.callId, + toolName: 'skill', + block, + openFile: vi.fn(), + inspect, + t, + } as unknown as SkillRowProps +} + +describe('SkillRow', () => { + it('renders a compact Bash-shaped summary and discloses the exact instructions', () => { + const inspect = vi.fn() + const view = render(<SkillRow {...props(settled(), inspect)} />) + const row = screen.getByRole('button', { name: 'Skill dsh-manage-issues' }) + expect(row.getAttribute('aria-expanded')).toBe('false') + expect(view.container.querySelector('[data-tool="skill"]')?.getAttribute('data-state')).toBe('ok') + expect(view.container.querySelector('[data-tool="skill"] svg')?.getAttribute('width')).toBe('16') + expect(screen.queryByLabelText('说明')).toBeNull() + + fireEvent.click(row) + expect(row.getAttribute('aria-expanded')).toBe('true') + const card = screen.getByLabelText('说明') + expect(card.textContent).toBe('说明Follow the issue workflow.\nKeep project fields in sync.') + expect(view.container.textContent).not.toContain('{"name":"dsh-manage-issues"}') + fireEvent.click(screen.getByRole('button', { name: 'Inspect' })) + expect(inspect).toHaveBeenCalledTimes(1) + + fireEvent.click(row) + expect(row.getAttribute('aria-expanded')).toBe('false') + }) + + it('supports Enter and Space while ignoring unrelated keys', () => { + render(<SkillRow {...props(settled())} />) + const row = screen.getByRole('button') + fireEvent.keyDown(row, { key: 'Escape' }) + expect(row.getAttribute('aria-expanded')).toBe('false') + fireEvent.keyDown(row, { key: 'Enter' }) + expect(row.getAttribute('aria-expanded')).toBe('true') + fireEvent.keyDown(row, { key: ' ' }) + expect(row.getAttribute('aria-expanded')).toBe('false') + }) + + it('keeps a running call compact and announces its state', () => { + const view = render(<SkillRow {...props(running())} />) + const row = view.container.querySelector('[data-tool="skill"] > div')! + expect(row.getAttribute('role')).toBeNull() + expect(view.container.textContent).toContain('正在加载 skill') + expect(view.container.textContent).toContain('dsh-manage-issues') + expect(view.container.querySelector('svg [fill="currentColor"]')).not.toBeNull() + }) + + it('uses the first failure line in the summary and exposes the full error', () => { + const view = render(<SkillRow {...props(settled({ + content: [{ type: 'text', text: 'SkillError: missing resource\nCheck SKILL.md.' }], + isError: true, + error: { name: 'SkillError', code: 'missing' }, + }))} />) + const row = screen.getByRole('button', { name: 'skill 加载失败 Skill SkillError: missing resource' }) + expect(view.container.querySelector('[data-tool="skill"]')?.getAttribute('data-state')).toBe('error') + expect(row.textContent).not.toContain('Check SKILL.md.') + fireEvent.click(row) + const output = view.container.querySelector('pre')! + expect(output.textContent).toBe('SkillError: missing resource\nCheck SKILL.md.') + expect(output.getAttribute('data-error')).toBe('true') + }) + + it('renders stopped, structured, and structured-error durable outcomes', () => { + const stoppedView = render(<SkillRow {...props(settled({ + error: { name: 'InterruptedError', code: 'interrupted' }, + }))} />) + expect(stoppedView.container.textContent).toContain('skill 加载已中止') + expect(stoppedView.container.querySelector('[data-state="warning"]')).not.toBeNull() + cleanup() + + const structuredView = render(<SkillRow {...props(settled({ + content: [{ type: 'reasoning', text: 'structured instruction note' }], + }))} />) + fireEvent.click(screen.getByRole('button')) + expect(structuredView.container.textContent).toContain('"type": "reasoning"') + cleanup() + + render(<SkillRow {...props(settled({ + content: [], + isError: true, + error: { name: 'SkillError', code: 'missing' }, + }))} />) + const errorRow = screen.getByRole('button', { name: 'skill 加载失败 Skill SkillError: missing' }) + fireEvent.click(errorRow) + expect(screen.getAllByText('SkillError: missing')).toHaveLength(2) + }) + + it('falls back to durable args or call id when the skill name is unavailable', () => { + const invalid = render(<SkillRow {...props(running('{"name":\n'))} />) + expect(invalid.container.textContent).toContain('{"name":') + cleanup() + + const scalar = render(<SkillRow {...props(running('"raw-name"'))} />) + expect(scalar.container.textContent).toContain('"raw-name"') + cleanup() + + const emptyName = render(<SkillRow {...props(running('{"name":""}'))} />) + expect(emptyName.container.textContent).toContain('{"name":""}') + cleanup() + + const blank = render(<SkillRow {...props(settled({ call: null, content: [] }))} />) + expect(blank.container.textContent).toContain('call-skill') + expect(blank.container.querySelector('[role="button"]')).toBeNull() + expect(blank.container.textContent).not.toContain('正在加载 skill') + }) +}) diff --git a/packages/client/ui-skill/tsconfig.json b/packages/client/ui-skill/tsconfig.json index 318a44906a..f83486aa36 100644 --- a/packages/client/ui-skill/tsconfig.json +++ b/packages/client/ui-skill/tsconfig.json @@ -14,9 +14,18 @@ { "path": "../connection" }, + { + "path": "../locale" + }, { "path": "../runtime" }, + { + "path": "../ui-conversation" + }, + { + "path": "../ui-primitives" + }, { "path": "../ui-slash" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 34c8d60cf9..09d0384667 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1942,9 +1942,21 @@ importers: '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../test-runtime + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives '@deepseek-ai/dsh-client-ui-slash': specifier: workspace:^ version: link:../ui-slash @@ -1954,9 +1966,21 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) packages/client/ui-slash: dependencies: diff --git a/tsconfig.host.json b/tsconfig.host.json index 4fcf71b680..1a0f4698f7 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -49,6 +49,7 @@ "apps/web/tests/startup-auto-selection.e2e.ts", "apps/web/tests/subagent-conversation.e2e.ts", "apps/web/tests/bash-abort-row.e2e.ts", + "apps/web/tests/skill-tool-row.e2e.ts", "apps/web/tests/chat-scroll-fixture.ts", "apps/web/tests/chat-scroll-contract.e2e.ts", "apps/web/tests/chat-long-interactions.e2e.ts", From 1ed5dc81d249a7d391fc9c03da3898fad0706517 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Thu, 6 Aug 2026 14:22:54 +0800 Subject: [PATCH 015/104] docs: refresh module graph --- docs/module-graph.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index 0e2e7e0c37..369441ff51 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -452,11 +452,6 @@ flowchart TD pkg_client_ui_layout --> pkg_client_ui_slots pkg_client_ui_layout --> pkg_client_ui_theme pkg_client_ui_layout --> pkg_invariants - pkg_client_ui_skill --> pkg_client_connection - pkg_client_ui_skill --> pkg_client_runtime - pkg_client_ui_skill --> pkg_client_ui_slash - pkg_client_ui_skill --> pkg_client_ui_slots - pkg_client_ui_skill --> pkg_invariants pkg_code_runtime_worker --> pkg_code_runtime pkg_code_runtime_worker --> pkg_invariants pkg_code_runtime_worker --> pkg_session @@ -834,6 +829,14 @@ flowchart TD pkg_client_ui_goal --> pkg_client_ui_slots pkg_client_ui_goal --> pkg_goal pkg_client_ui_goal --> pkg_invariants + pkg_client_ui_skill --> pkg_client_connection + pkg_client_ui_skill --> pkg_client_locale + pkg_client_ui_skill --> pkg_client_runtime + pkg_client_ui_skill --> pkg_client_ui_conversation + pkg_client_ui_skill --> pkg_client_ui_primitives + pkg_client_ui_skill --> pkg_client_ui_slash + pkg_client_ui_skill --> pkg_client_ui_slots + pkg_client_ui_skill --> pkg_invariants pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compact pkg_session_reference --> pkg_invariants @@ -1149,7 +1152,6 @@ flowchart TD | [`llm-replay`](../packages/support/llm-replay) | `support` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`app-boot`](../packages/ui/app-boot) | `ui` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | -| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | @@ -1224,6 +1226,7 @@ flowchart TD | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | +| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | From ee0e33e10f5be46a5854935459ae029c3c9496a1 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Thu, 6 Aug 2026 14:31:19 +0800 Subject: [PATCH 016/104] test(web): refresh markdown snapshots --- apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md | 2 +- .../tests/snapshots/markdown-inline-code-links/ui.expected.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md b/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md index 68a4df5603..187ab25e8c 100644 --- a/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md +++ b/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md @@ -40,7 +40,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}}Ran for {{duration}} +- text: {{clock}} Ran for {{duration}} - textbox "Message the agent" - button "Commands": - img diff --git a/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md b/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md index 059849223c..19efa06238 100644 --- a/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md +++ b/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md @@ -31,7 +31,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}}Ran for {{duration}} +- text: {{clock}} Ran for {{duration}} - textbox "Message the agent" - button "Commands": - img From e0e84d265a24b397663507bf8e9d97c1777a033d Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Thu, 6 Aug 2026 14:40:08 +0800 Subject: [PATCH 017/104] refactor(web): deduplicate skill disclosure leading --- .../client/ui-skill/src/client/SkillRow.tsx | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/client/ui-skill/src/client/SkillRow.tsx b/packages/client/ui-skill/src/client/SkillRow.tsx index c847678e4a..be1084ec39 100644 --- a/packages/client/ui-skill/src/client/SkillRow.tsx +++ b/packages/client/ui-skill/src/client/SkillRow.tsx @@ -85,6 +85,19 @@ function leadingFor(state: SkillRowState): ReactNode { } } +/** Leading disclosure slot: state icon at rest, chevron on hover or while open. */ +function disclosureLeading(state: SkillRowState, open: boolean, expandable: boolean): ReactNode { + if (open) return <IconChevronDownOutline14 className={css.chevron} /> + const icon = leadingFor(state) + if (!expandable) return icon + return ( + <> + <span className={css.iconIdle}>{icon}</span> + <IconChevronDownOutline14 className={`${css.chevron} ${css.chevronHover}`} /> + </> + ) +} + /** Visually hidden state copy for the colour-only lifecycle cues. */ function stateStatus(state: SkillRowState, t: SkillRowProps['t']): string | null { switch (state) { @@ -125,16 +138,7 @@ export function SkillRow({ block, inspect, t }: SkillRowProps) { event.preventDefault() toggleExpand() } - const leading = open - ? <IconChevronDownOutline14 className={css.chevron} /> - : expandable - ? ( - <> - <span className={css.iconIdle}>{leadingFor(model.state)}</span> - <IconChevronDownOutline14 className={`${css.chevron} ${css.chevronHover}`} /> - </> - ) - : leadingFor(model.state) + const leading = disclosureLeading(model.state, open, expandable) return ( <div className={css.card} data-tool="skill" data-state={model.state}> <div From a7c035285b810c8287e9b2028e0731da565340c7 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Thu, 6 Aug 2026 14:50:21 +0800 Subject: [PATCH 018/104] revert: leave markdown snapshots unchanged --- apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md | 2 +- .../tests/snapshots/markdown-inline-code-links/ui.expected.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md b/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md index 187ab25e8c..68a4df5603 100644 --- a/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md +++ b/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md @@ -40,7 +40,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} +- text: {{clock}}Ran for {{duration}} - textbox "Message the agent" - button "Commands": - img diff --git a/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md b/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md index 19efa06238..059849223c 100644 --- a/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md +++ b/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md @@ -31,7 +31,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} +- text: {{clock}}Ran for {{duration}} - textbox "Message the agent" - button "Commands": - img From 4e8067e8d52deec3d0320a9f5d4135034d74a976 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Thu, 6 Aug 2026 14:57:30 +0800 Subject: [PATCH 019/104] refactor(telemetry): replay feedback sessions without buffering --- ...3-session-telemetry-otel-revival.i18n.yaml | 4 +- ...26-07-23-session-telemetry-otel-revival.md | 6 +- ...07-23-session-telemetry-otel-revival.zh.md | 6 +- ...feedback-gated-session-telemetry.i18n.yaml | 4 +- ...-08-05-feedback-gated-session-telemetry.md | 12 +- ...-05-feedback-gated-session-telemetry.zh.md | 12 +- ...6-buffer-free-feedback-telemetry.i18n.yaml | 6 + ...26-08-06-buffer-free-feedback-telemetry.md | 29 +++ ...08-06-buffer-free-feedback-telemetry.zh.md | 29 +++ docs/config-catalog.md | 2 +- docs/cordis-catalog/events.md | 9 +- docs/event-producer-consumer.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- .../session-telemetry-otel/README.i18n.yaml | 4 +- .../session-telemetry-otel/README.md | 8 +- .../session-telemetry-otel/README.zh.md | 8 +- .../session-telemetry-otel/src/index.ts | 19 +- .../session-telemetry-otel/tests/otel.spec.ts | 2 +- .../session-telemetry/README.i18n.yaml | 4 +- .../telemetry/session-telemetry/README.md | 12 +- .../telemetry/session-telemetry/README.zh.md | 12 +- .../session-telemetry/src/coordinator.ts | 180 ++++++++---------- .../telemetry/session-telemetry/src/index.ts | 20 +- .../session-telemetry/tests/telemetry.spec.ts | 78 +++++--- 24 files changed, 270 insertions(+), 200 deletions(-) create mode 100644 .agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.i18n.yaml create mode 100644 .agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md create mode 100644 .agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.zh.md diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml index 3f487762d6..ecb7ca1588 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md -2026-07-23-session-telemetry-otel-revival.md: dcbff9757cbb730b66f456535fbd7ae471b6ffd1 -2026-07-23-session-telemetry-otel-revival.zh.md: c3a098041795fa92bb4e0dd421ca09be94907cb8 +2026-07-23-session-telemetry-otel-revival.md: f83128e8bf62e0718e59912c16c4e449855aaa1a +2026-07-23-session-telemetry-otel-revival.zh.md: 6f955cc9e1b44ed4a558515c4904b46a9cec585f diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md index dcbff9757c..f83128e8bf 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md @@ -12,9 +12,9 @@ Every deployment that wants harness sessions in an observability stack must hand `packages/telemetry/` revives the two reviewed packages under the SDK stance — the harness provides the capability, the deployment configures where records go and owns what leaves in them: -- **`@deepseek-ai/dsh-session-telemetry`** — the seam. `TelemetryBackend` (`emit`/`flush?`/`shutdown`), the service-registered `Telemetry` form, and `TelemetryCoordinator` owning capture: adoption with cursor read-back, the per-append firehose (project → `structuredClone` → redact → `emit`, zero I/O), the fixed first-chunk-per-(turn, step) projection, the `agent/error` relay, and dispose-time `shutdown` records. +- **`@deepseek-ai/dsh-session-telemetry`** — the seam. `TelemetryBackend` (`emit`/`flush?`/`shutdown`), the service-registered `Telemetry` form, and `TelemetryCoordinator` owning capture: live adoption with cursor read-back and the per-append firehose (project → `structuredClone` → redact → `emit`, zero I/O), buffer-free on-demand replay from the canonical log, the fixed first-chunk-per-(turn, step) projection, the live `agent/error` relay, and live dispose-time `shutdown` records. - **The `telemetry/record` waterfall** — the delta over the branch version and the seam's redaction extension point. Every record passes it before reaching any backend; the seam ships NO rules of its own — the innermost `next()` is a pass-through, deployments mount their rules as listeners (stacking by transforming `next()`'s return value), and a throwing rule withholds the record fail-closed. Redaction applies to the exported copy only; the canonical log is never rewritten. -- **`@deepseek-ai/dsh-session-telemetry-otel`** — the reference backend: OTel JS SDK log pipeline (`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter), configured verbatim through `exporter`/`processor` passthroughs. Its default `FULL` mode requires `exporter.url`; the later [feedback-gated telemetry decision](2026-08-05-feedback-gated-session-telemetry.md) adds `FEEDBACK_ONLY` and `DISABLED` delivery modes without moving the redaction or backend boundary. +- **`@deepseek-ai/dsh-session-telemetry-otel`** — the reference backend: OTel JS SDK log pipeline (`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter), configured verbatim through `exporter`/`processor` passthroughs. Its default `FULL` mode requires `exporter.url`; the later [feedback-gated telemetry decision](2026-08-05-feedback-gated-session-telemetry.md) adds `FEEDBACK_ONLY` and `DISABLED` delivery modes without moving the redaction or backend boundary, while [buffer-free feedback replay](../simplification/2026-08-06-buffer-free-feedback-telemetry.md) avoids a second in-memory copy of the session prefix. The boundary axiom holds: the harness's aspect ends at `emit()`. Batching, retry, queueing, and loss policy are the reporting SDK's, configured through passthroughs — delivery is best-effort (at-most-once across a crash), which the READMEs state plainly. @@ -34,4 +34,4 @@ The boundary axiom holds: the harness's aspect ends at `emit()`. Batching, retry ## Consequences -A deployment adds one `cordis.yml` entry with an OTLP endpoint and gets its session stream in any OTel-compatible stack. `FULL` preserves that behavior by default, `FEEDBACK_ONLY` withholds records until feedback releases a prefix, and `DISABLED` constructs no reporting pipeline; removing the entry remains a silent opt-out, while the disabled mode keeps the local feedback warning. A rule-free deployment exports records exactly as captured — including any credentials embedded in file contents or command output — so a deployment crossing a trust boundary must mount `telemetry/record` listeners, and both READMEs state this plainly. Where rules are mounted, exported bodies can differ from canonical log bytes, so receivers must not treat telemetry as a byte-exact replica; the log remains the source of truth. Crash durability is explicitly out of scope until the outbox decision above is revisited. +A deployment adds one `cordis.yml` entry with an OTLP endpoint and gets its session stream in any OTel-compatible stack. `FULL` preserves that behavior by default, `FEEDBACK_ONLY` replays a canonical-log prefix when feedback is recorded, and `DISABLED` constructs no reporting pipeline; removing the entry remains a silent opt-out, while the disabled mode keeps the local feedback warning. A rule-free deployment exports records exactly as captured — including any credentials embedded in file contents or command output — so a deployment crossing a trust boundary must mount `telemetry/record` listeners, and both READMEs state this plainly. Where rules are mounted, exported bodies can differ from canonical log bytes, so receivers must not treat telemetry as a byte-exact replica; the log remains the source of truth. Crash durability is explicitly out of scope until the outbox decision above is revisited. diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md index c3a0980417..6f955cc9e1 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md @@ -12,9 +12,9 @@ Status: implemented `packages/telemetry/` 以 SDK 立场复活这两个经过评审的包——harness 提供能力,部署方配置上报去向并对导出内容负责: -- **`@deepseek-ai/dsh-session-telemetry`** —— seam 本体。`TelemetryBackend`(`emit`/`flush?`/`shutdown`)、服务注册形态的 `Telemetry`、以及拥有捕获侧的 `TelemetryCoordinator`:带游标回读的收养、逐 append 的 firehose(投影 → `structuredClone` → 脱敏 → `emit`,零 I/O)、固定的每 (turn, step) 首 chunk 投影、`agent/error` 转发、以及 dispose 时的 `shutdown` 记录。 +- **`@deepseek-ai/dsh-session-telemetry`** —— seam 本体。`TelemetryBackend`(`emit`/`flush?`/`shutdown`)、服务注册形态的 `Telemetry`、以及拥有捕获侧的 `TelemetryCoordinator`:带游标回读的实时收养与逐 append 的 firehose(投影 → `structuredClone` → 脱敏 → `emit`,零 I/O)、从权威日志进行的无缓冲按需回放、固定的每 (turn, step) 首 chunk 投影、实时 `agent/error` 转发,以及实时 dispose 时的 `shutdown` 记录。 - **`telemetry/record` waterfall** —— 相对分支版本的增量,也是该 seam 的脱敏扩展点。每条记录抵达任何 backend 前必经此处;seam 自身不带任何规则——最内层 `next()` 原样透传,部署方以监听器挂载自己的规则(通过变换 `next()` 的返回值堆叠),抛异常的规则将该记录 fail-closed 扣下。脱敏只作用于导出副本;canonical log 永不改写。 -- **`@deepseek-ai/dsh-session-telemetry-otel`** —— 参考 backend:OTel JS SDK 日志管线(`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter),经 `exporter`/`processor` passthrough 原样配置。其默认 `FULL` 模式要求 `exporter.url`;后续的[反馈门控遥测决策](2026-08-05-feedback-gated-session-telemetry.md)增加了 `FEEDBACK_ONLY` 与 `DISABLED` 投递模式,但未移动脱敏或后端边界。 +- **`@deepseek-ai/dsh-session-telemetry-otel`** —— 参考 backend:OTel JS SDK 日志管线(`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter),经 `exporter`/`processor` passthrough 原样配置。其默认 `FULL` 模式要求 `exporter.url`;后续的[反馈门控遥测决策](2026-08-05-feedback-gated-session-telemetry.md)增加了 `FEEDBACK_ONLY` 与 `DISABLED` 投递模式,但未移动脱敏或后端边界,而[无缓冲反馈回放](../simplification/2026-08-06-buffer-free-feedback-telemetry.md)避免在内存中创建会话前缀的第二份副本。 边界公理保持不变:harness 的职责止于 `emit()`。批处理、重试、排队与丢失策略属于 reporting SDK,经 passthrough 配置——投递是尽力而为(崩溃时至多一次),README 对此如实陈述。 @@ -34,4 +34,4 @@ Status: implemented ## Consequences -部署方在 `cordis.yml` 加一个带 OTLP endpoint 的条目即可把会话流接入任何 OTel 兼容体系。`FULL` 默认保留该行为,`FEEDBACK_ONLY` 在反馈释放前暂存记录前缀,`DISABLED` 则不构造上报流水线;删除条目仍是静默退出方式,而禁用模式会保留本地反馈警告。未挂载规则的部署导出的记录与捕获时完全一致,包括文件内容与命令输出中内嵌的任何凭据。因此,跨信任边界的部署必须挂载 `telemetry/record` 监听器,两个 README 对此如实陈述。挂载规则后,导出的 body 可能与 canonical log 字节不同,接收端不得把遥测当作字节精确副本;日志仍是真源。崩溃持久性在上述 outbox 决定重启前明确不在范围内。 +部署方在 `cordis.yml` 加一个带 OTLP endpoint 的条目即可把会话流接入任何 OTel 兼容体系。`FULL` 默认保留该行为,`FEEDBACK_ONLY` 在记录反馈时回放权威日志前缀,`DISABLED` 则不构造上报流水线;删除条目仍是静默退出方式,而禁用模式会保留本地反馈警告。未挂载规则的部署导出的记录与捕获时完全一致,包括文件内容与命令输出中内嵌的任何凭据。因此,跨信任边界的部署必须挂载 `telemetry/record` 监听器,两个 README 对此如实陈述。挂载规则后,导出的 body 可能与 canonical log 字节不同,接收端不得把遥测当作字节精确副本;日志仍是真源。崩溃持久性在上述 outbox 决定重启前明确不在范围内。 diff --git a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml index d12ad78728..7909316acd 100644 --- a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-feedback-gated-session-telemetry.md -2026-08-05-feedback-gated-session-telemetry.md: 21a9028c603f3faaec39b2ddb8ef14644d6c84d4 -2026-08-05-feedback-gated-session-telemetry.zh.md: ea94c743b962a93a5fc64bdc2e4ed103aadecc99 +2026-08-05-feedback-gated-session-telemetry.md: 25cc17f75629f72d7351eb0537d72b700c84411f +2026-08-05-feedback-gated-session-telemetry.zh.md: b0e84e60e27fa20f66113c11db62026583a27a19 diff --git a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md index 21a9028c60..25cc17f756 100644 --- a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md +++ b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md @@ -13,23 +13,21 @@ Session telemetry originally has one mounted behavior: every accepted record ent `@deepseek-ai/dsh-session-telemetry-otel` exposes three uppercase `mode` values: - `FULL` is the default and preserves immediate delivery to the configured OTel pipeline. -- `FEEDBACK_ONLY` captures redacted copies in memory and releases the pending session prefix when `feedback/record` is appended. The released prefix includes the feedback event itself. Records appended after that event form another withheld prefix until another feedback event releases them. +- `FEEDBACK_ONLY` reads the canonical session log when `feedback/record` is appended and hands over the unreleased prefix through that exact event. Records appended after that boundary remain local until another feedback event. - `DISABLED` constructs no exporter, processor, or logger provider. A `feedback/record` listener prints that nothing is shared and the feedback remains local. -The generic telemetry coordinator owns the delivery distinction as `immediate` or `held`. Both paths project, clone, and run `telemetry/record` listeners at capture time. Immediate delivery sends the accepted record to the backend and advances the session's handoff cursor. Held delivery retains the accepted record per session without moving that cursor. `release(session)` submits the retained records in order, contains each backend failure independently, advances the cursor only for submitted records, and removes the released prefix. +The generic telemetry coordinator owns `live` and `on-demand` capture. Live capture projects, clones, redacts, and hands each event to the backend on the session firehose. On-demand capture registers no continuous capture listeners; `captureSession(session, throughSeq)` reads the canonical log from the handoff cursor through an inclusive boundary, then projects, clones, redacts, and hands over that prefix. The cursor advances only for handed-over records. The [buffer-free replay decision](../simplification/2026-08-06-buffer-free-feedback-telemetry.md) owns why the on-demand path uses the canonical log instead of copied records. -The OTel feedback listener is registered after the coordinator's session listener. Cordis therefore gives the coordinator the feedback append first, then the OTel listener releases a prefix that already contains that event. `exporter.url` is required in `FULL` and `FEEDBACK_ONLY`; `DISABLED` does not validate or use exporter configuration. +The OTel feedback listener passes the feedback event's sequence to `captureSession()`. `Session.append` commits the event before publishing `session/event`, so replay includes that feedback but cannot extend past its boundary. `exporter.url` is required in `FULL` and `FEEDBACK_ONLY`; `DISABLED` does not validate or use exporter configuration. ## Alternatives considered **Open a session permanently after its first feedback.** Rejected because later work would be shared without another feedback act and the plugin would need additional open-session state. Releasing one pending prefix per feedback has the smaller state machine and the narrower sharing boundary. -**Buffer after `TelemetryCoordinator.emit()` in the OTel backend.** Rejected because the coordinator would advance its handoff cursor before a record became eligible for upload. A plugin rebuild would then lose the only retained copy and incorrectly treat the prefix as handed off. - -**Replay the canonical session log when feedback arrives.** Rejected because replay would repeat projection and redaction, exclude telemetry operation records that are not session events, and require more lifecycle state to distinguish previously released prefixes. +**Retain capture-time redacted records until feedback.** Rejected because it duplicates an unbounded session prefix even though the canonical log already owns the events. It preserves capture-time redaction policy and operational records, but those properties do not justify the memory cost for a mode defined as uploading the session log after feedback. **Use an unmounted plugin as the disabled state.** That remains the silent opt-out, but it cannot warn when feedback is recorded. The explicit disabled mode lets a deployment keep one configuration shape and communicate that the local feedback did not leave the process. ## Consequences -`FULL` remains source- and wire-compatible with the original default. `FEEDBACK_ONLY` retains deep-copied, already-redacted records in process memory until feedback or session collection; a crash before release uploads nothing from that prefix. A clean shutdown after the last feedback is part of the new withheld suffix, so feedback-only streams do not carry a reliable shutdown or crash signal. Each later feedback releases the suffix accumulated since the previous one. `DISABLED` can omit `exporter.url`, does no reporting work, and keeps feedback only in the canonical session log. +`FULL` remains source- and wire-compatible with the original default. `FEEDBACK_ONLY` adds no telemetry-owned per-event buffer before feedback; a crash before feedback uploads nothing from that prefix. Replay applies the redaction policy mounted when feedback is recorded and excludes operational records that do not exist in the canonical log. Feedback-only streams therefore carry neither `agent-error` nor `shutdown` records, and shutdown absence is not a crash signal. Each later feedback captures the suffix accumulated since the previous boundary. `DISABLED` can omit `exporter.url`, does no reporting work, and keeps feedback only in the canonical session log. diff --git a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md index ea94c743b9..b0e84e60e2 100644 --- a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md @@ -13,23 +13,21 @@ Status: implemented `@deepseek-ai/dsh-session-telemetry-otel` 公开三个大写的 `mode` 值: - `FULL` 是默认值,保留向已配置 OTel 流水线的即时投递。 -- `FEEDBACK_ONLY` 在内存中捕获已脱敏副本,并在追加 `feedback/record` 时释放待处理的会话前缀。已释放前缀包含反馈事件本身。在该事件之后追加的记录会形成另一个暂存前缀,直到下一个反馈事件将其释放。 +- `FEEDBACK_ONLY` 在追加 `feedback/record` 时读取权威会话日志,并交接截至该事件的未释放前缀。该边界后追加的记录会留在本地,直到另一个反馈事件。 - `DISABLED` 不构造导出器、处理器或日志提供方。`feedback/record` 监听器会输出警告,说明什么都不会共享,且反馈仍留在本地。 -通用遥测协调器以 `immediate` 或 `held` 的形式拥有这两种投递方式。两条路径都会在捕获时进行投影、深拷贝,并运行 `telemetry/record` 监听器。即时投递把已接受记录发送到后端,并推进会话的 handoff 游标。暂存投递按会话保留已接受记录,且不移动该游标。`release(session)` 按顺序提交保留的记录,独立隔离每个后端失败,仅为已提交的记录推进游标,并移除已释放前缀。 +通用遥测协调器拥有 `live` 与 `on-demand` 捕获。实时捕获在会话 firehose 上投影、深拷贝、脱敏每个事件,并将其交给后端。按需捕获不注册持续捕获监听器;`captureSession(session, throughSeq)` 从 handoff 游标起读取权威日志,直至含边界的指定序列号,然后投影、深拷贝、脱敏并交接该前缀。游标只为已交接记录推进。[无缓冲回放决策](../simplification/2026-08-06-buffer-free-feedback-telemetry.md)说明了按需路径为何使用权威日志而非记录副本。 -OTel 反馈监听器在协调器的会话监听器之后注册。因此,Cordis 先将反馈追加交给协调器,再由 OTel 监听器释放已包含该事件的前缀。`exporter.url` 在 `FULL` 与 `FEEDBACK_ONLY` 中必填;`DISABLED` 不校验也不使用导出器配置。 +OTel 反馈监听器把反馈事件的序列号传给 `captureSession()`。`Session.append` 在发布 `session/event` 前已提交该事件,因此回放会包含该反馈,但不会超过其边界。`exporter.url` 在 `FULL` 与 `FEEDBACK_ONLY` 中必填;`DISABLED` 不校验也不使用导出器配置。 ## 考虑过的替代方案 **会话在首次反馈后永久开放。** 已否决,因为后续工作会在用户未再次提交反馈的情况下被共享,而且插件需要额外的会话开放状态。每次反馈只释放一个待处理前缀,状态机更小,共享边界也更窄。 -**在 OTel 后端的 `TelemetryCoordinator.emit()` 之后缓冲。** 已否决,因为协调器会在记录具备上传资格前推进 handoff 游标。插件重建后,唯一保留的副本会丢失,而协调器会错误地将该前缀视为已交接。 - -**反馈到达时回放权威会话日志。** 已否决,因为回放会重复执行投影与脱敏,排除不属于会话事件的遥测运维记录,且需要更多生命周期状态才能区分已释放前缀。 +**反馈前保留捕获时已脱敏记录。** 已否决,因为权威日志已拥有这些事件,该方案仍会复制无上限的会话前缀。它能保留捕获时的脱敏策略与运维记录,但对于一个定义为「反馈后上传会话日志」的模式,这些性质不足以证明该内存成本合理。 **以不挂载插件表示禁用状态。** 这仍然是静默退出方式,但无法在记录反馈时输出警告。显式禁用模式让部署方可以保持同一种配置形态,并说明本地反馈未离开进程。 ## 后果 -`FULL` 与原有默认值保持源码及协议兼容。`FEEDBACK_ONLY` 会在进程内存中保留已深拷贝且已脱敏的记录,直到收到反馈或会话被回收;释放前发生崩溃时,该前缀不上传任何内容。上次反馈之后的干净关闭属于新的暂存后缀,因此仅反馈的流不携带可靠的关闭或崩溃信号。每个后续反馈都会释放从上一个反馈开始累积的后缀。`DISABLED` 可省略 `exporter.url`,不执行任何上报工作,并仅在权威会话日志中保留反馈。 +`FULL` 与原有默认值保持源码及协议兼容。`FEEDBACK_ONLY` 在反馈前不增加遥测自有的逐事件缓冲;反馈前发生崩溃时,该前缀不上传任何内容。回放使用记录反馈时挂载的脱敏策略,并排除权威日志中不存在的运维记录。因此,仅反馈的流既不携带 `agent-error` 记录,也不携带 `shutdown` 记录,而缺少 shutdown 不是崩溃信号。每个后续反馈都会捕获从上一个边界起累积的后缀。`DISABLED` 可省略 `exporter.url`,不执行任何上报工作,并仅在权威会话日志中保留反馈。 diff --git a/.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.i18n.yaml new file mode 100644 index 0000000000..9f6288ac04 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.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/simplification/2026-08-06-buffer-free-feedback-telemetry.md +2026-08-06-buffer-free-feedback-telemetry.md: 008bebdcb59f7ef4fe49f8e731aad77861368d5c +2026-08-06-buffer-free-feedback-telemetry.zh.md: 7052e075921f4470864f5ea1c4aed5cf6201becf diff --git a/.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md b/.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md new file mode 100644 index 0000000000..008bebdcb5 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md @@ -0,0 +1,29 @@ +# Agent Note: Buffer-free feedback telemetry + +Status: implemented + +English | [中文](2026-08-06-buffer-free-feedback-telemetry.zh.md) + +## Problem + +Feedback-only telemetry must upload the session-log prefix only after recorded feedback. Retaining a deep-copied, redacted record for every projected event until that trigger duplicates the canonical session log and grows without a bound for a long-lived session that never records feedback. + +## Decision + +The telemetry coordinator provides `live` and `on-demand` capture. On-demand capture registers no session, flush, or operational-event listeners and retains no projected records. `captureSession(session, throughSeq?)` reads the canonical session log after the handoff cursor through an optional inclusive sequence boundary, applies the fixed projection, deep-copies each accepted event, runs the current `telemetry/record` waterfall, and hands the result to the backend. + +`FEEDBACK_ONLY` invokes that method with the `feedback/record` event's sequence. The append is already committed when `session/event` listeners run, so the replay contains the feedback event and cannot include a later suffix. The existing handoff cursor distinguishes later replays without another pending-record index. + +Because on-demand capture reads only the canonical log, it emits no `agent-error` or `shutdown` operational records. Redaction is evaluated at feedback time rather than append time. The [feedback mode decision](../feature/2026-08-05-feedback-gated-session-telemetry.md) owns the public sharing behavior; this note owns its buffer-free realization. + +## Alternatives considered + +**Retain capture-time redacted records.** This preserves the exact redaction policy and operational records observed when each event occurs, but duplicates the unbounded session prefix. The mode promises feedback-triggered session-log upload, not capture-time policy snapshots or pre-feedback operational telemetry. + +**Retain session event references or sequence numbers.** Rejected because the canonical log already supplies both order and identity. A second index saves payload copies but adds lifecycle state without enabling any required behavior. + +**Write a durable pre-feedback spool.** Deferred until a deployment requires crash recovery before feedback. It adds storage, cleanup, and confidentiality policy to a mode whose intended behavior is to upload nothing when the process exits before feedback. + +## Consequences + +A no-feedback session consumes no telemetry-owned memory proportional to its event count; the canonical session log remains the only pre-feedback copy. Feedback handling performs projection, cloning, and redaction synchronously before the backend's non-blocking enqueue, so its cost scales with the unreleased prefix. A redaction-policy change before feedback affects that replay, and a crash before feedback uploads nothing. Later feedback processes only events beyond the handoff cursor. diff --git a/.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.zh.md b/.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.zh.md new file mode 100644 index 0000000000..7052e07592 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 无缓冲反馈遥测 + +Status: implemented + +[English](2026-08-06-buffer-free-feedback-telemetry.md) | 中文 + +## 问题 + +仅反馈遥测必须只在记录反馈后上传会话日志前缀。若在触发前为每个已投影事件保留一份已深拷贝、已脱敏的记录,就会复制权威会话日志;对于长期运行但从不记录反馈的会话,这份副本会无限增长。 + +## 决策 + +遥测协调器提供 `live` 与 `on-demand` 捕获。按需捕获不注册会话、flush 或运维事件监听器,也不保留投影记录。`captureSession(session, throughSeq?)` 从 handoff 游标之后读取权威会话日志,直至可选的序列号边界(含边界),应用固定投影、深拷贝每个已接受事件、运行当前的 `telemetry/record` waterfall(瀑布式事件),并将结果交给后端。 + +`FEEDBACK_ONLY` 以 `feedback/record` 事件的序列号调用该方法。`session/event` 监听器运行时,追加已经提交,因此回放包含该反馈事件,且无法包含后续后缀。现有 handoff 游标可区分后续回放,无需另一个待处理记录索引。 + +按需捕获只读取权威日志,因此不会发出 `agent-error` 或 `shutdown` 运维记录。脱敏在反馈时而非追加时求值。[反馈模式决策](../feature/2026-08-05-feedback-gated-session-telemetry.md)规定公开的共享行为;本记录规定其无缓冲实现。 + +## 考虑过的替代方案 + +**保留捕获时的已脱敏记录。** 该方案会保留每个事件发生时观察到的确切脱敏策略与运维记录,但也会复制无上限的会话前缀。该模式承诺在反馈触发后上传会话日志,而非保留捕获时策略快照或反馈前运维遥测。 + +**保留会话事件引用或序列号。** 已否决,因为权威日志已同时提供顺序与身份。第二个索引可以省去载荷副本,但会增加生命周期状态,且无法实现任何必需行为。 + +**写入持久化的反馈前 spool。** 推迟到有部署要求反馈前的崩溃恢复时再实现。该方案会为一个预期在进程于反馈前退出时不上传任何内容的模式增加存储、清理与保密策略。 + +## 后果 + +没有反馈的会话不会消耗随事件数量增长的遥测自有内存;权威会话日志仍是反馈前的唯一副本。反馈处理会在后端非阻塞入队前同步执行投影、深拷贝与脱敏,因此其开销随未释放前缀增长。反馈前的脱敏策略变更会影响该次回放,而反馈前发生崩溃时什么都不上传。后续反馈只处理 handoff 游标之后的事件。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 0c4fb632c1..a4ddedab35 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1189,7 +1189,7 @@ export type TelemetryMode = typeof TELEMETRY_MODES[number] Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) · `OTLPExporterNodeConfigBase` (`@opentelemetry/otlp-exporter-base`) -Source: [`packages/telemetry/session-telemetry-otel/src/index.ts:54`](../packages/telemetry/session-telemetry-otel/src/index.ts) +Source: [`packages/telemetry/session-telemetry-otel/src/index.ts:55`](../packages/telemetry/session-telemetry-otel/src/index.ts) ## `@deepseek-ai/dsh-session-title` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index d159fa0a53..a416a17d46 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -830,7 +830,7 @@ Source: [`packages/core/system-prompt/src/index.ts:35`](../../packages/core/syst ### `telemetry/record` — waterfall -Transform one outbound record before it reaches the backend. This waterfall is the seam's redaction extension point. It ships NO rules of its own: the innermost `next()` passes the record through unchanged, and with no listener mounted records reach the backend as captured, so exported data is exactly as clean as the rules a deployment mounts. Listeners stack by transforming `next()`'s return value; returning without `next()` replaces everything beneath. Dispatched synchronously on the capture hot path inside the coordinator's containment: a throwing listener withholds that one record (fail-closed) and never reaches the agent loop. Redaction applies to the exported copy only; the canonical session log is never rewritten. +Transform one outbound record before it reaches the backend. This waterfall is the seam's redaction extension point. It ships NO rules of its own: the innermost `next()` passes the record through unchanged, and with no listener mounted records reach the backend as captured, so exported data is exactly as clean as the rules a deployment mounts. Listeners stack by transforming `next()`'s return value; returning without `next()` replaces everything beneath. Dispatched synchronously on the capture hot path inside the coordinator's containment: a throwing listener withholds that one record (fail-closed) and never reaches the agent loop. Live capture dispatches at append time; on-demand capture dispatches while reading the canonical log. Redaction applies to the exported copy only; the canonical session log is never rewritten. ```ts cordis-catalog /** @@ -844,8 +844,9 @@ Transform one outbound record before it reaches the backend. This waterfall is t * `next()` replaces everything beneath. Dispatched synchronously on the * capture hot path inside the coordinator's containment: a throwing * listener withholds that one record (fail-closed) and never reaches the - * agent loop. Redaction applies to the exported copy only; the canonical - * session log is never rewritten. + * agent loop. Live capture dispatches at append time; on-demand capture + * dispatches while reading the canonical log. Redaction applies to the + * exported copy only; the canonical session log is never rewritten. * @param record - the candidate record, already the coordinator's own deep * copy; listeners return a (possibly new) record and must not mutate it. * @mode waterfall @@ -853,7 +854,7 @@ Transform one outbound record before it reaches the backend. This waterfall is t 'telemetry/record'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord ``` -Source: [`packages/telemetry/session-telemetry/src/index.ts:42`](../../packages/telemetry/session-telemetry/src/index.ts) +Source: [`packages/telemetry/session-telemetry/src/index.ts:43`](../../packages/telemetry/session-telemetry/src/index.ts) ## `tools/*` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 4ccc19f305..82000e7f9a 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -45,7 +45,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:131`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | -| `telemetry/record` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:42`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - | +| `telemetry/record` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:43`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:156`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | | `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:138`](../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:113`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index d4061261db..2298c9b4c6 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1340,7 +1340,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'telemetry/record', mode: 'waterfall', signature: '\'telemetry/record\'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord', - jsDoc: '/**\n * Transform one outbound record before it reaches the backend. This\n * waterfall is the seam\'s redaction extension point. It ships NO rules\n * of its own: the\n * innermost `next()` passes the record through unchanged, and with no\n * listener mounted records reach the backend as captured, so exported\n * data is exactly as clean as the rules a deployment mounts. Listeners\n * stack by transforming `next()`\'s return value; returning without\n * `next()` replaces everything beneath. Dispatched synchronously on the\n * capture hot path inside the coordinator\'s containment: a throwing\n * listener withholds that one record (fail-closed) and never reaches the\n * agent loop. Redaction applies to the exported copy only; the canonical\n * session log is never rewritten.\n * @param record - the candidate record, already the coordinator\'s own deep\n * copy; listeners return a (possibly new) record and must not mutate it.\n * @mode waterfall\n */', + jsDoc: '/**\n * Transform one outbound record before it reaches the backend. This\n * waterfall is the seam\'s redaction extension point. It ships NO rules\n * of its own: the\n * innermost `next()` passes the record through unchanged, and with no\n * listener mounted records reach the backend as captured, so exported\n * data is exactly as clean as the rules a deployment mounts. Listeners\n * stack by transforming `next()`\'s return value; returning without\n * `next()` replaces everything beneath. Dispatched synchronously on the\n * capture hot path inside the coordinator\'s containment: a throwing\n * listener withholds that one record (fail-closed) and never reaches the\n * agent loop. Live capture dispatches at append time; on-demand capture\n * dispatches while reading the canonical log. Redaction applies to the\n * exported copy only; the canonical session log is never rewritten.\n * @param record - the candidate record, already the coordinator\'s own deep\n * copy; listeners return a (possibly new) record and must not mutate it.\n * @mode waterfall\n */', summary: 'Transform one outbound record before it reaches the backend.', }, { diff --git a/packages/telemetry/session-telemetry-otel/README.i18n.yaml b/packages/telemetry/session-telemetry-otel/README.i18n.yaml index 6557557b8c..84e2447fd8 100644 --- a/packages/telemetry/session-telemetry-otel/README.i18n.yaml +++ b/packages/telemetry/session-telemetry-otel/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/telemetry/session-telemetry-otel/README.md -README.md: fab2461477b2174bded42ed6f05ae55c7c5f697c -README.zh.md: ab0191188836e03434adbce527d31b62ead848a3 +README.md: 7fc5572614a5bdba312ba97b52606032ef8f5394 +README.zh.md: 3160b67c8225fb87d5e7be2e43453ef40496fba9 diff --git a/packages/telemetry/session-telemetry-otel/README.md b/packages/telemetry/session-telemetry-otel/README.md index fab2461477..7fc5572614 100644 --- a/packages/telemetry/session-telemetry-otel/README.md +++ b/packages/telemetry/session-telemetry-otel/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — the only entry a deployment loads. Its `mode` decides whether the seam hands records over immediately, releases them only at recorded feedback, or keeps telemetry local. Uploading modes compose the OTel JS SDK as-is (`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP log exporter) and map each handed-over record onto `logger.emit()`, under two instrumentation scopes: ledger records on `@deepseek-ai/dsh-session-telemetry-otel`, operational records on `@deepseek-ai/dsh-session-telemetry-otel/ops`. Resource identity (`service.name`/`service.version`) comes from `dsh-llm`'s `APP_IDENTITY`, the same source the attribution headers use. +The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — the only entry a deployment loads. Its `mode` decides whether the seam follows session events live, replays the canonical log only at recorded feedback, or keeps telemetry local. Uploading modes compose the OTel JS SDK as-is (`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP log exporter) and map each handed-over record onto `logger.emit()`, under two instrumentation scopes: ledger records on `@deepseek-ai/dsh-session-telemetry-otel`, operational records on `@deepseek-ai/dsh-session-telemetry-otel/ops`. Resource identity (`service.name`/`service.version`) comes from `dsh-llm`'s `APP_IDENTITY`, the same source the attribution headers use. ## Config @@ -21,14 +21,14 @@ The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — th | `mode` | Behavior | |---|---| | `FULL` | Default. Each projected record, including lifecycle ops records, is handed to the OTel SDK immediately. | -| `FEEDBACK_ONLY` | Each `feedback/record` releases the redacted, projected session prefix through that event. Later records wait for another feedback event and remain local if none arrives. | +| `FEEDBACK_ONLY` | Each `feedback/record` replays, projects, and redacts the canonical session-log suffix through that event. Later records wait for another feedback event and remain local if none arrives. | | `DISABLED` | No coordinator, provider, processor, or exporter is constructed. No telemetry record leaves the process. A `feedback/record` logs `session telemetry is DISABLED; nothing will be shared and this feedback remains local`; the event remains in the local session log. | `exporter.url` is required in `FULL` and `FEEDBACK_ONLY`, has no default, and must parse as `http(s)`; it is optional and unused in `DISABLED`. Uploading modes also reject a non-positive-integer `processor.maxExportBatchSize`, which the SDK accepts but then hangs on at shutdown. Everything else is the SDK's option shape, owned and documented by the SDK, and both blocks pass through whole: every `OTLPExporterNodeConfigBase` field (`headers`, `timeoutMillis`, `compression`, `keepAlive`, …) reaches the exporter, and batching, export cadence (`scheduledDelayMillis`), retry, queue bounds, and loss policy under sustained failure are the SDK's documented behavior, tuned through the `processor` passthrough. The backend deliberately implements no `flush()`: the batch processor is the only flusher in the process, which is what makes `shutdown()`'s drain complete. ## What leaves the machine -In uploading modes, records carry the complete `event.data` as the seam's `telemetry/record` waterfall returns it — user and assistant message content, tool arguments and results (command output, file contents), the full system prompt and tool schemas (`request/header`), todo text, compaction summaries, hook `stderrSummary`, feedback text, and the session `cwd` (a local path). The seam ships no redaction rules: with no `telemetry/record` listener mounted, that is the raw captured copy, so a deployment exporting beyond a trusted boundary mounts its own rules (see [the seam README](../session-telemetry/README.md#the-redact-waterfall)). Provider credentials never appear regardless: adapter API keys are constructor parameters, not session events, so they are structurally absent from the log and therefore from telemetry. `DISABLED` does not construct the SDK pipeline or hand any capture to a backend. +In uploading modes, records carry the complete `event.data` as the seam's `telemetry/record` waterfall returns it — user and assistant message content, tool arguments and results (command output, file contents), the full system prompt and tool schemas (`request/header`), todo text, compaction summaries, hook `stderrSummary`, feedback text, and the session `cwd` (a local path). The seam ships no redaction rules: with no `telemetry/record` listener mounted, that is the raw captured copy, so a deployment exporting beyond a trusted boundary mounts its own rules (see [the seam README](../session-telemetry/README.md#the-redact-waterfall)). `FULL` runs redaction at append time; `FEEDBACK_ONLY` retains no telemetry copy and runs the currently mounted rules when feedback triggers canonical-log replay. Provider credentials never appear regardless: adapter API keys are constructor parameters, not session events, so they are structurally absent from the log and therefore from telemetry. `DISABLED` does not construct the SDK pipeline or hand any capture to a backend. ## Field mapping @@ -46,4 +46,4 @@ None; this package neither assembles nor sends a provider request. - **Upstream experimental tree** — `@opentelemetry/sdk-logs` is still published from the upstream experimental tree; SDK API churn lands here and only here — the seam contract does not move. - **No live-collector coverage** — every test exports to a local mock collector; the keyless Loader-composition e2e (`tests/loader-composition.e2e.ts`) covers the wire shape on every run, and behavior against a real OTLP deployment (auth, TLS, throttling) is the SDK exporter's documented territory. -- **Feedback-only memory** — each session retains deep-copied, redacted projected records in memory until feedback releases them or the session becomes unreachable. There is no durable pre-feedback spool; a crash before feedback uploads nothing. +- **Feedback-time snapshot** — `FEEDBACK_ONLY` retains no telemetry-owned copy before feedback. It reads and redacts the current canonical log when feedback is recorded; a crash before feedback uploads nothing, and policy changes before feedback affect what that replay exports. diff --git a/packages/telemetry/session-telemetry-otel/README.zh.md b/packages/telemetry/session-telemetry-otel/README.zh.md index ab01911888..3160b67c82 100644 --- a/packages/telemetry/session-telemetry-otel/README.zh.md +++ b/packages/telemetry/session-telemetry-otel/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -[遥测(telemetry)seam](../session-telemetry/) 的 OpenTelemetry 后端,也是部署方唯一要加载的条目。其 `mode` 决定 seam 是立即交接记录、仅在记录反馈时释放记录,还是将遥测留在本地。上传模式会原样组合 OTel JS SDK(`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP 日志导出器),把每条已交接记录映射到 `logger.emit()`,并使用两个插桩作用域(instrumentation scope):ledger 记录挂在 `@deepseek-ai/dsh-session-telemetry-otel` 下,运维记录挂在 `@deepseek-ai/dsh-session-telemetry-otel/ops` 下。资源身份(`service.name`/`service.version`)来自 `dsh-llm` 的 `APP_IDENTITY`,与归因标头同源。 +[遥测(telemetry)seam](../session-telemetry/) 的 OpenTelemetry 后端,也是部署方唯一要加载的条目。其 `mode` 决定 seam 是实时跟随会话事件、仅在记录反馈时回放权威日志,还是将遥测留在本地。上传模式会原样组合 OTel JS SDK(`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP 日志导出器),把每条已交接记录映射到 `logger.emit()`,并使用两个插桩作用域(instrumentation scope):ledger 记录挂在 `@deepseek-ai/dsh-session-telemetry-otel` 下,运维记录挂在 `@deepseek-ai/dsh-session-telemetry-otel/ops` 下。资源身份(`service.name`/`service.version`)来自 `dsh-llm` 的 `APP_IDENTITY`,与归因标头同源。 ## 配置 @@ -21,14 +21,14 @@ | `mode` | 行为 | |---|---| | `FULL` | 默认值。每条已投影记录都立即交给 OTel SDK,包括生命周期运维记录。 | -| `FEEDBACK_ONLY` | 每个 `feedback/record` 都会释放截至该事件的已脱敏、已投影会话前缀。后续记录等待下一个反馈事件;如果没有后续反馈,则留在本地。 | +| `FEEDBACK_ONLY` | 每个 `feedback/record` 都会回放权威会话日志中截至该事件的后缀,并进行投影与脱敏。后续记录等待下一个反馈事件;如果没有后续反馈,则留在本地。 | | `DISABLED` | 不构造协调器、提供方、处理器或导出器。没有遥测记录会离开进程。`feedback/record` 会记录 `session telemetry is DISABLED; nothing will be shared and this feedback remains local`;该事件留在本地会话日志中。 | `exporter.url` 在 `FULL` 与 `FEEDBACK_ONLY` 中必填,无默认值,且必须能解析为 `http(s)`;在 `DISABLED` 中可省略且不使用。上传模式也会拒绝不是正整数的 `processor.maxExportBatchSize`,SDK 虽会接受该值,但随后会在关闭时挂起。其余全部是 SDK 自己的选项形态,由 SDK 拥有并在 SDK 文档中说明,两个配置块都整体透传(passthrough):`OTLPExporterNodeConfigBase` 的每个字段(`headers`、`timeoutMillis`、`compression`、`keepAlive` 等)都会到达导出器;批处理、导出节奏(`scheduledDelayMillis`)、重试、队列上限,以及持续失败下的丢失策略,都是 SDK 的文档化行为,经 `processor` 透传调优。该后端刻意不实现 `flush()`:批处理器是进程内唯一执行 flush 的组件,`shutdown()` 的排空正因如此才是完整的。 ## 哪些数据会离开本机 -在上传模式中,记录携带完整的 `event.data`,内容以 seam 的 `telemetry/record` waterfall(瀑布式事件)返回的结果为准:用户与 assistant 消息内容、工具参数与工具结果(命令输出、文件内容)、完整的系统提示词与工具 schema(`request/header`)、todo 文本、压缩(compaction)摘要、钩子的 `stderrSummary`、反馈文本,以及会话 `cwd`(一个本地路径)。seam 不带任何脱敏规则:未挂载 `telemetry/record` 监听器时,导出的就是捕获原样的副本,因此向可信边界之外导出的部署方要挂载自己的规则(见 [seam README](../session-telemetry/README.md#the-redact-waterfall))。无论如何,提供方凭据都不会出现:适配器的 API key 是构造函数参数而非会话事件,因此它们在结构上就不存在于日志中,也就不存在于遥测中。`DISABLED` 不会构造 SDK 流水线,也不会将任何捕获内容交给后端。 +在上传模式中,记录携带完整的 `event.data`,内容以 seam 的 `telemetry/record` waterfall(瀑布式事件)返回的结果为准:用户与 assistant 消息内容、工具参数与工具结果(命令输出、文件内容)、完整的系统提示词与工具 schema(`request/header`)、todo 文本、压缩(compaction)摘要、钩子的 `stderrSummary`、反馈文本,以及会话 `cwd`(一个本地路径)。seam 不带任何脱敏规则:未挂载 `telemetry/record` 监听器时,导出的就是捕获原样的副本,因此向可信边界之外导出的部署方要挂载自己的规则(见 [seam README](../session-telemetry/README.md#the-redact-waterfall))。`FULL` 在追加时运行脱敏;`FEEDBACK_ONLY` 不保留遥测副本,而是在反馈触发权威日志回放时运行当时挂载的规则。无论如何,提供方凭据都不会出现:适配器的 API key 是构造函数参数而非会话事件,因此它们在结构上就不存在于日志中,也就不存在于遥测中。`DISABLED` 不会构造 SDK 流水线,也不会将任何捕获内容交给后端。 ## 字段映射 @@ -46,4 +46,4 @@ seam 记录 → SDK 日志记录:`time` → `timestamp`/`observedTimestamp`; - **上游实验性源码树**:`@opentelemetry/sdk-logs` 仍从上游实验性(experimental)源码树发布;SDK API 的变动只会落在本包,也仅落在本包;seam 契约不动。 - **无真实 collector 覆盖**:所有测试都导出到本地 mock collector;无密钥的 Loader 组合 e2e(`tests/loader-composition.e2e.ts`)在每次运行中都覆盖协议格式(wire format)形态,而面对真实 OTLP 部署的行为(认证、TLS、限流)属于 SDK 导出器文档的职责范围。 -- **仅反馈模式的内存占用**:每个会话都会在内存中保留已深拷贝、已脱敏的投影记录,直到反馈将其释放或会话变得不可达。反馈前不存在持久化 spool;如果在反馈前崩溃,则什么都不上传。 +- **反馈时快照**:`FEEDBACK_ONLY` 在反馈前不保留遥测自有副本。记录反馈时,它读取并脱敏当前的权威日志;反馈前发生崩溃时什么都不上传,而反馈前的策略变更会影响该次回放的导出内容。 diff --git a/packages/telemetry/session-telemetry-otel/src/index.ts b/packages/telemetry/session-telemetry-otel/src/index.ts index cb0ee71fc7..908f0f90fb 100644 --- a/packages/telemetry/session-telemetry-otel/src/index.ts +++ b/packages/telemetry/session-telemetry-otel/src/index.ts @@ -7,7 +7,8 @@ * boundary axiom, everything downstream of that call (batching, retry, * queueing, loss policy) is the SDK's documented behavior, configured * verbatim through the `exporter`/`processor` passthroughs. This package owns - * only whether capture is immediate, feedback-released, or disabled. + * only whether capture is live, feedback-triggered from the canonical log, or + * disabled. * * @module @deepseek-ai/dsh-session-telemetry-otel */ @@ -19,7 +20,7 @@ import type {} from '@deepseek-ai/dsh-command-feedback' import { Telemetry, TelemetryCoordinator, - type TelemetryDelivery, + type TelemetryCapture, type TelemetryRecord, type TelemetrySeverity, } from '@deepseek-ai/dsh-session-telemetry' @@ -161,13 +162,13 @@ export class TelemetryOtel extends Telemetry { }) this.ledger = this.provider.getLogger('@deepseek-ai/dsh-session-telemetry-otel', version) this.ops = this.provider.getLogger('@deepseek-ai/dsh-session-telemetry-otel/ops', version) - const delivery: TelemetryDelivery = mode === 'FULL' ? 'immediate' : 'held' - const coordinator = new TelemetryCoordinator(ctx, this, delivery) + const capture: TelemetryCapture = mode === 'FULL' ? 'live' : 'on-demand' + const coordinator = new TelemetryCoordinator(ctx, this, capture) if (mode === 'FEEDBACK_ONLY') { - // The coordinator listener is registered first, so a feedback event - // enters the held prefix before this listener releases that exact prefix. + // Session.append commits before publishing `session/event`, so the + // canonical log already includes this feedback record when replay begins. ctx.on('session/event', (session, event) => { - if (event.type === 'feedback/record') coordinator.release(session) + if (event.type === 'feedback/record') coordinator.captureSession(session, event.seq) }) } } @@ -206,8 +207,8 @@ export class TelemetryOtel extends Telemetry { * quiesce. With no concurrent `forceFlush()` in the process (see above), * shutdown's internal drain is complete — everything handed to the SDK * before this call is exported before the exporter closes. In `FULL`, that - * includes dispose-time `shutdown` markers; held suffixes never reach the - * SDK. Awaited (and error-contained) by the coordinator's disposer. A + * includes dispose-time `shutdown` markers; `FEEDBACK_ONLY` creates no ops + * records. Awaited (and error-contained) by the coordinator's disposer. A * disabled backend resolves immediately. * @returns resolves when the SDK pipeline has quiesced. */ diff --git a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts index 18c466f7aa..9b7e4119b7 100644 --- a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts +++ b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts @@ -206,7 +206,7 @@ describe('TelemetryOtel wire', () => { expect(start?.record.severityNumber).toBe(13) }) - it('holds each session suffix until the next feedback event', async () => { + it('replays each session suffix only at the next feedback event', async () => { const { url, captures } = await mockCollector() const ctx = new Context() await ctx.plugin(SessionStore) diff --git a/packages/telemetry/session-telemetry/README.i18n.yaml b/packages/telemetry/session-telemetry/README.i18n.yaml index da3a62e2fd..ee9c7288b1 100644 --- a/packages/telemetry/session-telemetry/README.i18n.yaml +++ b/packages/telemetry/session-telemetry/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/telemetry/session-telemetry/README.md -README.md: d38433a728c699c7fb3cc0512bb6a2d977dd4cc6 -README.zh.md: 3a86b01321fc7dfd33d39530ee7fa38a6ee1f2dc +README.md: 67d95bcc62bbf6783f8dcd11f0236d8c926b557b +README.zh.md: 1ee0e0eb14bb06c8ac669cd417f2ee2ce46ca430 diff --git a/packages/telemetry/session-telemetry/README.md b/packages/telemetry/session-telemetry/README.md index d38433a728..67d95bcc62 100644 --- a/packages/telemetry/session-telemetry/README.md +++ b/packages/telemetry/session-telemetry/README.md @@ -2,23 +2,23 @@ English | [中文](README.zh.md) -The telemetry seam: the capture side of session-event reporting, behind a backend contract any reporting SDK satisfies with zero bending. Capture can hand each redacted record over immediately or hold a per-session prefix for an explicit release. The boundary axiom that shapes everything here: **this package's aspect ends at `emit()`** — batching, retry, queueing, and loss policy belong to the backend's SDK and are neither specified nor wrapped. Rationale and rejected alternatives: [the revival Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md) and [feedback-gated delivery](../../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md). +The telemetry seam: the capture side of session-event reporting, behind a backend contract any reporting SDK satisfies with zero bending. Capture can follow live session events or replay a canonical session-log prefix on demand. The boundary axiom that shapes everything here: **this package's aspect ends at `emit()`** — batching, retry, queueing, and loss policy belong to the backend's SDK and are neither specified nor wrapped. Rationale and rejected alternatives: [the revival Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md), [feedback-gated delivery](../../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md), and [buffer-free feedback replay](../../../.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md). ## The backend contract -`TelemetryBackend` is three members: `emit(record)` (MUST be a non-blocking enqueue — it runs synchronously on the `session/event` hot path, either at capture or held-prefix release), optional `flush()` (a turn-boundary hint, fire-and-forget; most backends leave it unimplemented and let their SDK's batching cadence govern export timing — an implementer owns the interaction between concurrent flushes and `shutdown()`'s drain), and `shutdown()` (the lifecycle forward: drain-and-quiesce, awaited at dispose). `Telemetry` is its service-registered form under the `telemetry` context key — one implementation per context, duplicate load throws. A backend composes `TelemetryCoordinator` with `immediate` delivery or `held` delivery and calls `release(session)` at its owning trigger. +`TelemetryBackend` is three members: `emit(record)` (MUST be a non-blocking enqueue — it runs synchronously on the `session/event` hot path or during an explicit canonical-log replay), optional `flush()` (a turn-boundary hint, fire-and-forget; most backends leave it unimplemented and let their SDK's batching cadence govern export timing — an implementer owns the interaction between concurrent flushes and `shutdown()`'s drain), and `shutdown()` (the lifecycle forward: drain-and-quiesce, awaited at dispose). `Telemetry` is its service-registered form under the `telemetry` context key — one implementation per context, duplicate load throws. A backend composes `TelemetryCoordinator` with `live` capture or `on-demand` capture and calls `captureSession(session, throughSeq?)` at its owning trigger. ## Capture points -The coordinator registers, all through the composing fiber's effects: `session/created` (adopt: record the header, read the log back through the projection from the construction boundary — constructor seeds from fork/resume never re-emit on the firehose and never re-export), `session/event` (project, deep-copy, redact, then hand off or hold; zero I/O), `session/flush` (forward the optional `flush()` hint and return void — the loop's awaited parallel must never wait on telemetry), `session/disposed` (capture the session's `shutdown` operational record at its termination edge, then retire it), `agent/error` (the one live-bus relay; the session event vocabulary intentionally has no operational-error record), a dispose effect (capture shutdown for each still-live session, then await the backend's `shutdown()`; failures warn instead of throwing), and an adoption sweep of `ctx.sessions.list()` (a hot reload does not replay `session/created`). Immediate delivery hands lifecycle records over; held delivery leaves any suffix after the last release local, including its later shutdown marker. +In `live` mode the coordinator registers, all through the composing fiber's effects: `session/created` (adopt: record the header, read the log back through the projection from the construction boundary — constructor seeds from fork/resume never re-emit on the firehose and never re-export), `session/event` (project, deep-copy, redact, then hand off; zero I/O), `session/flush` (forward the optional `flush()` hint and return void — the loop's awaited parallel must never wait on telemetry), `session/disposed` (capture the session's `shutdown` operational record at its termination edge, then retire it), `agent/error` (the one live-bus relay; the session event vocabulary intentionally has no operational-error record), a dispose effect (capture shutdown for each still-live session, then await the backend's `shutdown()`; failures warn instead of throwing), and an adoption sweep of `ctx.sessions.list()` (a hot reload does not replay `session/created`). In `on-demand` mode it registers only the dispose effect: `captureSession()` reads the canonical log through an optional inclusive sequence boundary, while flush hints and operational events remain local. ## The redact waterfall -Every record passes the `telemetry/record` waterfall immediately after projection — the seam's scrubbing extension point. The seam ships NO rules of its own: the innermost `next()` passes the record through unchanged, so with no listener mounted records reach the backend exactly as captured, and exported data is precisely as clean as the rules a deployment mounts. Listeners stack by transforming `next()`'s return value; returning without `next()` replaces everything beneath, and a throwing listener withholds that one record fail-closed inside the coordinator's containment. Held delivery stores only the waterfall result, so later policy removal cannot expose the original capture. Redaction applies to the outbound copy only; the canonical session log is never rewritten. +Every record passes the `telemetry/record` waterfall immediately after projection — the seam's scrubbing extension point. The seam ships NO rules of its own: the innermost `next()` passes the record through unchanged, so with no listener mounted records reach the backend exactly as captured, and exported data is precisely as clean as the rules a deployment mounts. Listeners stack by transforming `next()`'s return value; returning without `next()` replaces everything beneath, and a throwing listener withholds that one record fail-closed inside the coordinator's containment. Live capture runs the waterfall at append time; on-demand capture runs it while replaying the canonical log, using the rules mounted at that time. Redaction applies to the outbound copy only; the canonical session log is never rewritten. ## The handoff cursor -A module-scope `WeakMap<Session, seq>` marks the highest seq HANDED OFF (not delivered) per session. Immediate delivery advances it at capture; held delivery advances it only when `release(session)` hands that record to the backend. An unreleased prefix therefore survives a coordinator reload through deterministic re-adoption instead of disappearing with its in-memory copy. On re-adoption the coordinator re-hands only events past the cursor (events at or below it still rebuild the chunk-projection state); a missing cursor safely degrades to a re-hand from the session's construction boundary (`Session.firstLiveSeq` — seq 0 for a session born in this process), absorbed by receiver-side dedupe on `(session.id, event.seq)`. Constructor seeds never re-export: a resumed session's history shipped from the previous process under the same id, and a fork's inherited prefix lives in the parent's stream (receivers stitch on `session.parent_id` + `session.seed_length`). The accepted cost, consistent with at-most-once delivery: a resume does not backfill records a previous process failed to deliver — a deployment with a backfill requirement needs the deferred outbox, not replay. This is a deliberate, narrow exception to the registrations-are-effects discipline: entries die with their sessions, the value is a monotonic watermark, and losing it is never an error. +A module-scope `WeakMap<Session, seq>` marks the highest seq HANDED OFF (not delivered) per session. Live capture advances it at append time; on-demand capture advances it only while `captureSession()` hands a requested prefix to the backend. An uncaptured prefix remains solely in the canonical log, so a coordinator reload adds no telemetry-owned recovery state. On replay the coordinator re-hands only events past the cursor (events at or below it still rebuild the chunk-projection state); a missing cursor safely degrades to a re-hand from the session's construction boundary (`Session.firstLiveSeq` — seq 0 for a session born in this process), absorbed by receiver-side dedupe on `(session.id, event.seq)`. Constructor seeds never re-export: a resumed session's history shipped from the previous process under the same id, and a fork's inherited prefix lives in the parent's stream (receivers stitch on `session.parent_id` + `session.seed_length`). The accepted cost, consistent with at-most-once delivery: a resume does not backfill records a previous process failed to deliver — a deployment with a backfill requirement needs the deferred outbox, not replay. This is a deliberate, narrow exception to the registrations-are-effects discipline: entries die with their sessions, the value is a monotonic watermark, and losing it is never an error. ## The fixed chunk projection @@ -40,4 +40,4 @@ None; this package neither assembles nor sends a provider request. - **Best-effort delivery** — the cursor marks handed-off, not delivered; a session torn down inside a reload window cannot be re-adopted; whatever sits in a backend queue at crash time is lost. A durable outbox (spool, per-sink cursors, at-least-once) is deferred until a deployment states a crash-loss requirement — see [the revival Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md). - **No built-in redaction rules** — with no `telemetry/record` listener mounted, records leave the process exactly as captured, including any credentials embedded in file contents or command output; a deployment exporting to a shared collector owns its rule set. -- **Held prefixes duplicate memory** — held delivery retains one deep-copied, redacted record per projected event until release or session collection. It adds no durable outbox and intentionally trades memory for a simple no-upload-before-trigger boundary. +- **On-demand redaction uses current state** — uncaptured events exist only in the canonical session log. A later `captureSession()` deep-copies and redacts their current values with the policy mounted at that time; there is no capture-time telemetry snapshot or durable pre-capture spool. diff --git a/packages/telemetry/session-telemetry/README.zh.md b/packages/telemetry/session-telemetry/README.zh.md index 3a86b01321..1ee0e0eb14 100644 --- a/packages/telemetry/session-telemetry/README.zh.md +++ b/packages/telemetry/session-telemetry/README.zh.md @@ -2,23 +2,23 @@ [English](README.md) | 中文 -遥测(telemetry)seam:会话事件上报的捕获侧,隔在一个后端契约之后,任何上报 SDK 都无需变形即可满足该契约。捕获侧可立即交接每条已脱敏记录,也可按会话暂存一个前缀,等待显式释放。塑造本包(package)一切设计的边界公理:**本包的职责止于 `emit()`**。批处理、重试、排队与丢失策略都属于后端自身的 SDK,本包既不为其立规,也不做包装。设计依据与被否决的替代方案见[复活 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)与[反馈门控投递](../../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md)。 +遥测(telemetry)seam:会话事件上报的捕获侧,隔在一个后端契约之后,任何上报 SDK 都无需变形即可满足该契约。捕获侧可跟随实时会话事件,也可按需回放权威会话日志前缀。塑造本包(package)一切设计的边界公理:**本包的职责止于 `emit()`**。批处理、重试、排队与丢失策略都属于后端自身的 SDK,本包既不为其立规,也不做包装。设计依据与被否决的替代方案见[复活 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)、[反馈门控投递](../../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md)与[无缓冲反馈回放](../../../.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md)。 ## 后端契约 -`TelemetryBackend` 只有三个成员:`emit(record)`(必须是非阻塞入队;它会在捕获或暂存前缀释放时,于 `session/event` 热路径上同步执行)、可选的 `flush()`(轮次边界提示,触发后不等待结果;多数后端不实现它,而由其 SDK 的批处理节奏决定导出时机;并发 flush 与 `shutdown()` 的排空之间的交互由实现方自行负责)、以及 `shutdown()`(生命周期转发点:排空并完全停稳,在 dispose(资源释放)时被等待)。`Telemetry` 是它注册在 `telemetry` 上下文键下的服务形态:每个上下文只允许一个实现,重复加载会抛出异常。后端以 `immediate` 或 `held` 投递模式组合 `TelemetryCoordinator`,并在自身所属的触发器中调用 `release(session)`。 +`TelemetryBackend` 只有三个成员:`emit(record)`(必须是非阻塞入队;它在 `session/event` 热路径或显式权威日志回放期间同步执行)、可选的 `flush()`(轮次边界提示,触发后不等待结果;多数后端不实现它,而由其 SDK 的批处理节奏决定导出时机;并发 flush 与 `shutdown()` 的排空之间的交互由实现方自行负责)、以及 `shutdown()`(生命周期转发点:排空并完全停稳,在 dispose(资源释放)时被等待)。`Telemetry` 是它注册在 `telemetry` 上下文键下的服务形态:每个上下文只允许一个实现,重复加载会抛出异常。后端以 `live` 或 `on-demand` 模式组合 `TelemetryCoordinator`,并在自身所属的触发器中调用 `captureSession(session, throughSeq?)`。 ## 捕获点 -协调器的全部注册都经由组合方 fiber 的 effect 完成:`session/created`(收养:记录 header,并经投影从构造边界起回读日志;来自 fork 或恢复的构造函数种子绝不会在 firehose 上再次发出,也绝不会再次导出)、`session/event`(投影、深拷贝、脱敏,再交接或暂存;零 I/O)、`session/flush`(转发可选的 `flush()` 提示并返回 void;循环所等待的并行任务绝不能等待遥测)、`session/disposed`(在会话自身的终止边缘捕获该会话的 `shutdown` 运维记录,然后将其退役)、`agent/error`(唯一的实时总线转发;会话事件词汇有意不包含运维错误记录)、一个 dispose effect(捕获每个仍存活会话的 shutdown,再等待后端的 `shutdown()`;失败只发出警告而不抛出),以及对 `ctx.sessions.list()` 的收养扫描(热重载不会重放 `session/created`)。即时投递会交接生命周期记录;暂存投递会将上次释放后的任何后缀留在本地,包括随后的 shutdown 标记。 +在 `live` 模式中,协调器的全部注册都经由组合方 fiber 的 effect 完成:`session/created`(收养:记录 header,并经投影从构造边界起回读日志;来自 fork 或恢复的构造函数种子绝不会在 firehose 上再次发出,也绝不会再次导出)、`session/event`(投影、深拷贝、脱敏,再交接;零 I/O)、`session/flush`(转发可选的 `flush()` 提示并返回 void;循环所等待的并行任务绝不能等待遥测)、`session/disposed`(在会话自身的终止边缘捕获该会话的 `shutdown` 运维记录,然后将其退役)、`agent/error`(唯一的实时总线转发;会话事件词汇有意不包含运维错误记录)、一个 dispose effect(捕获每个仍存活会话的 shutdown,再等待后端的 `shutdown()`;失败只发出警告而不抛出),以及对 `ctx.sessions.list()` 的收养扫描(热重载不会重放 `session/created`)。在 `on-demand` 模式中,协调器只注册 dispose effect:`captureSession()` 读取权威日志,直至可选的序列号边界(含边界);flush 提示与运维事件留在本地。 ## 脱敏 waterfall(瀑布式事件) -每条记录在投影后立即经过 `telemetry/record` waterfall,这是该 seam 的脱敏扩展点。seam 自身不带任何规则:最内层的 `next()` 原样透传记录,因此未挂载监听器时,记录以捕获时的原样到达后端;导出数据能干净到什么程度,恰恰取决于部署方挂载了什么规则。监听器通过变换 `next()` 的返回值来堆叠;不调用 `next()` 就返回,即替换其下方的全部逻辑;抛出异常的监听器会在协调器的隔离范围内以 fail-closed 方式拦下这一条记录。暂存投递只保留 waterfall 的结果,因此后续移除策略也无法暴露捕获时的原始内容。脱敏只作用于外发副本;权威会话日志永不改写。 +每条记录在投影后立即经过 `telemetry/record` waterfall,这是该 seam 的脱敏扩展点。seam 自身不带任何规则:最内层的 `next()` 原样透传记录,因此未挂载监听器时,记录以捕获时的原样到达后端;导出数据能干净到什么程度,恰恰取决于部署方挂载了什么规则。监听器通过变换 `next()` 的返回值来堆叠;不调用 `next()` 就返回,即替换其下方的全部逻辑;抛出异常的监听器会在协调器的隔离范围内以 fail-closed 方式拦下这一条记录。实时捕获在追加时运行 waterfall;按需捕获则在回放权威日志时使用当时挂载的规则运行 waterfall。脱敏只作用于外发副本;权威会话日志永不改写。 ## handoff 游标 -一个模块作用域的 `WeakMap<Session, seq>` 记录每个会话已交接(而非已投递)的最高 seq。即时投递在捕获时推进游标;暂存投递只有在 `release(session)` 将记录交给后端时才推进游标。因此,重建协调器后会通过确定性重新收养恢复未释放的前缀,而不会随其内存副本一同消失。重新收养时,协调器只重新交接游标之后的事件(游标及其之前的事件仍用于重建分片投影状态);游标缺失时安全退化为从会话构造边界起的重新交接(`Session.firstLiveSeq`,对在本进程中诞生的会话即 seq 0),由接收端基于 `(session.id, event.seq)` 的去重吸收。构造函数种子绝不会再次导出:恢复会话的历史已由上一个进程以同一 id 发出,fork 继承的前缀则位于父会话的流中(接收端基于 `session.parent_id` + `session.seed_length` 拼接)。由此接受的代价与至多一次(at-most-once)投递一致:恢复不会回填上一个进程未能投递的记录;有回填要求的部署需要的是已推迟的 outbox,而不是回放。这是对「注册即 effect」纪律的一次有意且范围极窄的例外:条目随其会话消亡,值是单调水位线,丢失它绝不是错误。 +一个模块作用域的 `WeakMap<Session, seq>` 记录每个会话已交接(而非已投递)的最高 seq。实时捕获在追加时推进游标;按需捕获只有在 `captureSession()` 将请求的前缀交给后端时才推进游标。未捕获的前缀只留在权威日志中,因此协调器重载不会增加遥测自有的恢复状态。回放时,协调器只重新交接游标之后的事件(游标及其之前的事件仍用于重建分片投影状态);游标缺失时安全退化为从会话构造边界起的重新交接(`Session.firstLiveSeq`,对在本进程中诞生的会话即 seq 0),由接收端基于 `(session.id, event.seq)` 的去重吸收。构造函数种子绝不会再次导出:恢复会话的历史已由上一个进程以同一 id 发出,fork 继承的前缀则位于父会话的流中(接收端基于 `session.parent_id` + `session.seed_length` 拼接)。由此接受的代价与至多一次(at-most-once)投递一致:恢复不会回填上一个进程未能投递的记录;有回填要求的部署需要的是已推迟的 outbox,而不是回放。这是对「注册即 effect」纪律的一次有意且范围极窄的例外:条目随其会话消亡,值是单调水位线,丢失它绝不是错误。 ## 固定分片投影 @@ -40,4 +40,4 @@ - **尽力而为的投递**:游标标记的是已交接而非已投递;在重载窗口内被拆除的会话无法重新收养;崩溃时留在后端队列中的内容会丢失。持久化 outbox(spool、每 sink 游标、at-least-once)推迟到有部署方提出明确的崩溃丢失要求时再实现;见[复活 Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)。 - **不内置脱敏规则**:未挂载 `telemetry/record` 监听器时,记录以捕获时的原样离开进程,包括文件内容或命令输出中内嵌的任何凭据;向共享 collector 导出的部署方自行负责其规则集。 -- **暂存前缀会重复占用内存**:暂存投递会为每个已投影事件保留一份深拷贝且已脱敏的记录,直到释放或回收会话。它不增加持久化 outbox,而是有意以内存换取简单的「触发前不上传」边界。 +- **按需脱敏使用当前状态**:未捕获的事件只存在于权威会话日志中。后续的 `captureSession()` 会使用当时挂载的策略,深拷贝并脱敏其当前值;不存在捕获时的遥测快照或持久化的捕获前 spool。 diff --git a/packages/telemetry/session-telemetry/src/coordinator.ts b/packages/telemetry/session-telemetry/src/coordinator.ts index 710e9b81f9..9e32ae0693 100644 --- a/packages/telemetry/session-telemetry/src/coordinator.ts +++ b/packages/telemetry/session-telemetry/src/coordinator.ts @@ -1,13 +1,15 @@ /** - * Capture coordinator: the seam's upstream half. Subscribes to the session - * firehose plus the one live-bus relay (`agent/error`), applies the fixed - * chunk projection, builds logical records, runs each through the + * Capture coordinator: the seam's upstream half. Live capture subscribes to + * the session firehose plus the one live-bus relay (`agent/error`). Both + * capture paths apply the fixed chunk projection, build logical records, and + * run each through the * `telemetry/record` waterfall (deployment-mounted redaction rules; - * pass-through when none), then hands the result to the backend immediately - * or holds it for explicit release. Every synchronous handler is - * self-contained so a failing backend can never starve other subscribers - * (cordis `emit` is stop-on-throw) or touch the agent loop. Composed by a - * backend in its constructor. + * pass-through when none), then hands the result to the backend. Live capture + * follows the session firehose; on-demand capture replays the canonical log + * only when requested. Every synchronous handler is self-contained so a + * failing backend can never starve other subscribers (cordis `emit` is + * stop-on-throw) or touch the agent loop. Composed by a backend in its + * constructor. * * @module @deepseek-ai/dsh-session-telemetry/coordinator */ @@ -17,11 +19,11 @@ import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import type { TelemetryBackend, TelemetryRecord, TelemetrySeverity } from './index.ts' -/** Whether capture hands records over immediately or holds them for an explicit release. */ -export type TelemetryDelivery = 'immediate' | 'held' +/** Whether capture follows live events or reads the canonical log only when requested. */ +export type TelemetryCapture = 'live' | 'on-demand' -/** One redacted record waiting at the capture boundary. */ -interface PendingRecord { +/** One projected record ready for backend handoff. */ +interface ProjectedRecord { readonly record: TelemetryRecord /** Ledger cursor advanced only after the backend accepts this record. */ readonly seq?: number @@ -43,16 +45,17 @@ const handoffCursor = new WeakMap<Session, number>() /** * Install the telemetry capture side onto a context for one backend. * - * Registers the persistence-coordinator listener set plus the `agent/error` - * relay, all through `ctx.effect()`/`ctx.on()` on the composing fiber, and - * sweeps already-live sessions (a hot reload does not replay + * Live capture registers the persistence-coordinator listener set plus the + * `agent/error` relay, all through `ctx.effect()`/`ctx.on()` on the composing + * fiber, and sweeps already-live sessions (a hot reload does not replay * `session/created`). A `session/disposed` captures the session's `shutdown` * operational record at its own termination edge and retires it from the - * adopted set. Immediate delivery hands that marker over; held delivery keeps - * it local without another explicit release. Disposal captures the same - * marker for sessions still alive, then awaits the backend's `shutdown()`; a - * failure there warns instead of throwing — best-effort reporting must not - * fail application teardown. + * adopted set. On-demand capture registers none of those continuous listeners; + * {@link captureSession} reads the canonical log explicitly and never creates + * operational records. Disposal captures shutdown markers for live-adopted + * sessions, then awaits the backend's `shutdown()`; a failure there warns + * instead of throwing — best-effort reporting must not fail application + * teardown. */ export class TelemetryCoordinator { /** @@ -63,56 +66,55 @@ export class TelemetryCoordinator { private readonly adopted = new Set<Session>() /** Per session, the `turn:step` keys whose first chunk already shipped; rebuilt from the log on re-adoption. */ private readonly chunkSeen = new WeakMap<Session, Set<string>>() - /** Redacted records retained until {@link release}; weak keys do not extend session lifetime. */ - private readonly held = new WeakMap<Session, PendingRecord[]>() - /** * @param ctx - the composing backend's context; listeners bind to its fiber. * @param backend - the backend receiving records; owned elsewhere, never disposed here beyond `shutdown()` forwarding. - * @param delivery - immediate handoff, or held delivery released explicitly per session. + * @param capture - follow live events, or wait for explicit canonical-log capture. */ constructor( private readonly ctx: Context, private readonly backend: TelemetryBackend, - private readonly delivery: TelemetryDelivery = 'immediate', + capture: TelemetryCapture = 'live', ) { - ctx.on('session/created', (session) => { - this.adopt(session) - }) - // Capture the shutdown marker at the session's own termination edge. - // Immediate delivery preserves crash classification; held delivery does - // not let a later lifecycle edge extend a user-released prefix. Then - // retire the only strong reference owned by this coordinator. - ctx.on('session/disposed', (session) => { - this.contain(() => { - if (!this.adopted.delete(session)) return - this.submit(session, { record: this.redact(shutdownRecord(session)) }) + if (capture === 'live') { + ctx.on('session/created', (session) => { + this.adopt(session) }) - }) - ctx.on('session/event', (session, event) => { - this.contain(() => { - this.capture(session, event) + // Capture the shutdown marker at the session's own termination edge, + // then retire the only strong reference owned by this coordinator. + ctx.on('session/disposed', (session) => { + this.contain(() => { + if (!this.adopted.delete(session)) return + this.deliver(session, { record: this.redact(shutdownRecord(session)) }) + }) }) - }) - // Parallel listeners are awaited by the loop at turn end; returning void - // (not the SDK's flush promise) is the turn-latency contract. - ctx.on('session/flush', (session) => { - this.contain(() => { - this.hintFlush(session) + ctx.on('session/event', (session, event) => { + this.contain(() => { + this.captureEvent(session, event) + }) }) - }) - ctx.on('agent/error', (agent, turn, step, error) => { - this.contain(() => { - this.relayAgentError(agent, turn, step, error) + // Parallel listeners are awaited by the loop at turn end; returning void + // (not the SDK's flush promise) is the turn-latency contract. + ctx.on('session/flush', (session) => { + this.contain(() => { + this.hintFlush(session) + }) }) - }) + ctx.on('agent/error', (agent, turn, step, error) => { + this.contain(() => { + this.relayAgentError(agent, turn, step, error) + }) + }) + for (const session of ctx.sessions.list()) { + this.adopt(session) + } + } ctx.effect(() => async () => { // Sessions still adopted here are alive through whole-application - // teardown, so capture the marker before the backend quiesces. Held - // delivery intentionally leaves it local without another release. + // teardown, so capture the marker before the backend quiesces. for (const session of this.adopted) { this.contain(() => { - this.submit(session, { record: this.redact(shutdownRecord(session)) }) + this.deliver(session, { record: this.redact(shutdownRecord(session)) }) }) } try { @@ -121,24 +123,27 @@ export class TelemetryCoordinator { this.ctx.logger.warn(`telemetry: backend shutdown failed: ${String(error)}`) } }, 'telemetry capture') - for (const session of ctx.sessions.list()) { - this.adopt(session) - } } /** - * Hand the records currently held for one session to the backend in capture order. - * Records captured after this call form a new held prefix. Backend failures remain - * contained per record and do not starve later records in the same release. - * @param session - session whose pending capture prefix may leave the process. + * Project and hand over the canonical session-log suffix after the handoff + * cursor, optionally stopping at an inclusive sequence boundary. Redaction + * runs during this call, so an on-demand caller retains no copied records + * before requesting capture and uses the policy mounted at that time. + * Backend and policy failures remain contained per event and do not starve + * later events in the same replay. + * @param session - session whose current canonical-log prefix may be handed over. + * @param throughSeq - optional last sequence included in this capture. */ - release(session: Session): void { - const pending = this.held.get(session) - if (pending === undefined) return - this.held.delete(session) - for (const record of pending) { + captureSession(session: Session, throughSeq?: number): void { + const cursor = handoffCursor.get(session) ?? session.firstLiveSeq - 1 + // Containment is PER EVENT: one rejected record is withheld fail-closed + // while the rest of the historical replay proceeds. + for (const event of session.events) { + if (throughSeq !== undefined && event.seq > throughSeq) break this.contain(() => { - this.deliver(session, record) + if (event.seq <= cursor) this.track(session, event) + else this.captureEvent(session, event) }) } } @@ -161,17 +166,7 @@ export class TelemetryCoordinator { private adopt(session: Session): void { if (this.adopted.has(session)) return this.adopted.add(session) - const cursor = handoffCursor.get(session) ?? session.firstLiveSeq - 1 - // Containment is PER EVENT, matching the firehose: one rejected record - // is withheld fail-closed while the rest of the historical replay - // proceeds — wrapping the whole loop would let a single failure silently - // skip the remainder of the log on an already-adopted session. - for (const event of session.events) { - this.contain(() => { - if (event.seq <= cursor) this.track(session, event) - else this.capture(session, event) - }) - } + this.captureSession(session) } /** Feed the chunk projection without handing off — the ≤cursor half of re-adoption. */ @@ -181,8 +176,8 @@ export class TelemetryCoordinator { } } - /** Project and redact one event, then submit it under the delivery policy. */ - private capture(session: Session, event: SessionEvent): void { + /** Project, redact, and hand one event to the backend. */ + private captureEvent(session: Session, event: SessionEvent): void { if (event.type === 'assistant/chunk') { const key = `${event.data.turn}:${event.data.step}` const seen = this.seen(session) @@ -193,14 +188,14 @@ export class TelemetryCoordinator { if (seen.has(key)) return seen.add(key) } - this.submit(session, { + this.deliver(session, { record: this.redact({ channel: 'ledger', time: event.time, severity: severityOf(event), attributes: identityOf(session, event), - // The live event object is mutable and the backend serializes later; - // append-time validation guarantees this clone cannot throw. + // The canonical event object is mutable and the backend serializes + // later; append-time validation guarantees this clone cannot throw. body: structuredClone(event.data), }), seq: event.seq, @@ -212,26 +207,15 @@ export class TelemetryCoordinator { * passes the record through unchanged — the seam ships no rules; exported * data is as clean as the listeners a deployment mounts. Callers run inside * {@link contain}, so a throwing rule withholds the record instead of - * reaching the loop (fail-closed). Held delivery stores only this result, so - * a later policy reload cannot expose the pre-redaction capture. + * reaching the loop (fail-closed). On-demand capture invokes this waterfall + * while reading the canonical session log, not when the event was appended. */ private redact(record: TelemetryRecord): TelemetryRecord { return this.ctx.waterfall('telemetry/record', record, () => record) } - /** Hold one redacted record or deliver it immediately under the configured policy. */ - private submit(session: Session, pending: PendingRecord): void { - if (this.delivery === 'held') { - let records = this.held.get(session) - if (records === undefined) this.held.set(session, records = []) - records.push(pending) - return - } - this.deliver(session, pending) - } - /** Hand one redacted record to the backend, then advance its ledger cursor. */ - private deliver(session: Session, pending: PendingRecord): void { + private deliver(session: Session, pending: ProjectedRecord): void { this.backend.emit(pending.record) if (pending.seq !== undefined) handoffCursor.set(session, pending.seq) } @@ -244,7 +228,7 @@ export class TelemetryCoordinator { /** Relay one `agent/error` bus emission as an `agent-error` operational record. */ private relayAgentError(agent: Agent, turn: number, step: number, error: unknown): void { const detail = errorDetail(error) - this.submit(agent.session, { + this.deliver(agent.session, { record: this.redact({ channel: 'ops', time: Date.now(), diff --git a/packages/telemetry/session-telemetry/src/index.ts b/packages/telemetry/session-telemetry/src/index.ts index 914ef96a95..0198df3140 100644 --- a/packages/telemetry/session-telemetry/src/index.ts +++ b/packages/telemetry/session-telemetry/src/index.ts @@ -4,9 +4,9 @@ * The seam owns the CAPTURE side of session-event reporting — which records * exist (the chunk projection), what they carry (the logical record), when * they are captured (adoption, the per-append firehose, lifecycle - * forwarding), immediate versus explicitly released handoff, and the HMR + * forwarding), live versus on-demand canonical-log capture, and the HMR * cursor. Everything downstream of - * {@link Telemetry.emit} — batching, retry, queueing, loss policy — is the + * {@link Telemetry.emit} — batching, retry, queueing, and loss policy — is the * reporting SDK's territory and is deliberately not modelled here. The * design and its trade-offs are pinned in * .agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md. @@ -33,8 +33,9 @@ declare module 'cordis' { * `next()` replaces everything beneath. Dispatched synchronously on the * capture hot path inside the coordinator's containment: a throwing * listener withholds that one record (fail-closed) and never reaches the - * agent loop. Redaction applies to the exported copy only; the canonical - * session log is never rewritten. + * agent loop. Live capture dispatches at append time; on-demand capture + * dispatches while reading the canonical log. Redaction applies to the + * exported copy only; the canonical session log is never rewritten. * @param record - the candidate record, already the coordinator's own deep * copy; listeners return a (possibly new) record and must not mutate it. * @mode waterfall @@ -95,8 +96,8 @@ export interface TelemetryBackend { /** * Hand one record to the backend's pipeline. MUST be a non-blocking * enqueue — the coordinator calls this synchronously from the - * `session/event` hot path, either at capture or while releasing a held - * prefix, so anything slower than a queue push would tax the agent loop. + * `session/event` hot path or an explicit canonical-log capture, so anything + * slower than a queue push would tax the agent loop or feedback handling. * Errors thrown here are contained by the coordinator and logged; they * never reach the loop. * @param record - the logical record to report; owned by the backend after the call. @@ -123,9 +124,8 @@ export interface TelemetryBackend { * coordinator emits its dispose-time `shutdown` markers immediately before * calling this). Awaited by the coordinator's dispose; a rejection is * logged as a warning and never fails application teardown. - * The coordinator captures dispose-time shutdown markers immediately - * before this call; immediate delivery enqueues them, while held delivery - * leaves an unreleased suffix local. + * The coordinator captures dispose-time shutdown markers immediately before + * this call for live capture; on-demand capture creates no ops records. * @returns resolves when the backend's pipeline has quiesced. */ shutdown(): Promise<void> @@ -158,4 +158,4 @@ export abstract class Telemetry extends Service implements TelemetryBackend { abstract shutdown(): Promise<void> } -export { TelemetryCoordinator, type TelemetryDelivery } from './coordinator.ts' +export { TelemetryCoordinator, type TelemetryCapture } from './coordinator.ts' diff --git a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts index d913e6a742..f368e80979 100644 --- a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts +++ b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts @@ -13,7 +13,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import { TelemetryCoordinator, type TelemetryBackend, - type TelemetryDelivery, + type TelemetryCapture, type TelemetryRecord, } from '../src/index.ts' @@ -61,7 +61,7 @@ class FakeBackend implements TelemetryBackend { async function setup( backend: FakeBackend = new FakeBackend(), - delivery: TelemetryDelivery = 'immediate', + capture: TelemetryCapture = 'live', ) { const ctx = new Context() await ctx.plugin(SessionStore) @@ -70,7 +70,7 @@ async function setup( name: 'fake-telemetry', inject: ['sessions'], apply: (inner: Context) => { - coordinator = new TelemetryCoordinator(inner, backend, delivery) + coordinator = new TelemetryCoordinator(inner, backend, capture) }, }) return { ctx, backend, coordinator, fiber } @@ -178,23 +178,24 @@ describe('TelemetryCoordinator capture', () => { }) }) -describe('TelemetryCoordinator held delivery', () => { - it('releases one pending prefix at a time without handing later records over early', async () => { - const { ctx, backend, coordinator } = await setup(new FakeBackend(), 'held') - const session = liveSession(ctx, 'held-prefix') +describe('TelemetryCoordinator on-demand capture', () => { + it('captures one canonical-log prefix at a time without following later events', async () => { + const { ctx, backend, coordinator } = await setup(new FakeBackend(), 'on-demand') + const session = liveSession(ctx, 'on-demand-prefix') appendTurn(session) + const firstBoundary = session.events[1]!.seq + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) expect(backend.records).toEqual([]) - coordinator.release(session) + coordinator.captureSession(session, firstBoundary) expect(backend.ledger().map(record => record.attributes['event.type'])).toEqual([ 'turn/start', 'user/message', ]) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) expect(backend.ledger()).toHaveLength(2) - coordinator.release(session) - coordinator.release(session) + coordinator.captureSession(session) + coordinator.captureSession(session) expect(backend.ledger().map(record => record.attributes['event.type'])).toEqual([ 'turn/start', 'user/message', @@ -202,38 +203,42 @@ describe('TelemetryCoordinator held delivery', () => { ]) }) - it('stores the capture-time redacted copy rather than re-running policy at release', async () => { - const { ctx, backend, coordinator } = await setup(new FakeBackend(), 'held') + it('runs the currently mounted redaction policy during canonical-log capture', async () => { + const { ctx, backend, coordinator } = await setup(new FakeBackend(), 'on-demand') + const session = liveSession(ctx, 'on-demand-redacted') + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) const disposeRule = ctx.on('telemetry/record', (_record, next) => ({ ...next(), body: { scrubbed: true }, })) - const session = liveSession(ctx, 'held-redacted') - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + + coordinator.captureSession(session) + expect(backend.ledger()[0]!.body).toEqual({ scrubbed: true }) disposeRule() - coordinator.release(session) - expect(backend.ledger()[0]!.body).toEqual({ scrubbed: true }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + coordinator.captureSession(session) + expect(backend.ledger()[1]!.body).toEqual({ turn: 1, reason: { kind: 'completed' } }) }) - it('contains each backend failure independently while releasing a batch', async () => { + it('contains each backend failure independently while replaying a prefix', async () => { const backend = new FakeBackend() backend.rejectSeq = 1 - const { ctx, coordinator } = await setup(backend, 'held') + const { ctx, coordinator } = await setup(backend, 'on-demand') const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) - const session = liveSession(ctx, 'held-failure') + const session = liveSession(ctx, 'on-demand-failure') appendTurn(session) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - coordinator.release(session) + coordinator.captureSession(session) expect(backend.ledger().map(record => record.attributes['event.seq'])).toEqual([0, 2]) expect(warn).toHaveBeenCalled() }) - it('rebuilds an unreleased prefix after coordinator reload', async () => { + it('captures a pending prefix after coordinator reload without retained records', async () => { const first = new FakeBackend() - const { ctx, fiber } = await setup(first, 'held') - const session = liveSession(ctx, 'held-reload') + const { ctx, fiber } = await setup(first, 'on-demand') + const session = liveSession(ctx, 'on-demand-reload') session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) await fiber.dispose() expect(first.records).toEqual([]) @@ -241,15 +246,34 @@ describe('TelemetryCoordinator held delivery', () => { const second = new FakeBackend() let coordinator!: TelemetryCoordinator await ctx.plugin({ - name: 'fake-telemetry-after-held-reload', + name: 'fake-telemetry-after-on-demand-reload', inject: ['sessions'], apply: (inner: Context) => { - coordinator = new TelemetryCoordinator(inner, second, 'held') + coordinator = new TelemetryCoordinator(inner, second, 'on-demand') }, }) - coordinator.release(session) + coordinator.captureSession(session) expect(second.ledger().map(record => record.attributes['event.seq'])).toEqual([0]) }) + + it('registers no continuous capture, flush, or ops listeners', async () => { + const { ctx, backend, coordinator, fiber } = await setup(new FakeBackend(), 'on-demand') + const redact = vi.fn((_record: TelemetryRecord, next: () => TelemetryRecord) => next()) + ctx.on('telemetry/record', redact) + const session = liveSession(ctx, 'on-demand-ledger-only') + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + await ctx.parallel('session/flush', session) + const agent = { id: 'agent-1', session } as Agent + ctx.emit('agent/error', agent, 1, 1, new Error('local only')) + expect(backend.flush).not.toHaveBeenCalled() + expect(backend.records).toEqual([]) + expect(redact).not.toHaveBeenCalled() + + coordinator.captureSession(session) + expect(redact).toHaveBeenCalledTimes(1) + await fiber.dispose() + expect(backend.records.map(record => record.channel)).toEqual(['ledger']) + }) }) describe('TelemetryCoordinator adoption', () => { From c6f8055388feda7b5298e5d03fb9b9f46fa778cf Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Thu, 6 Aug 2026 15:03:52 +0800 Subject: [PATCH 020/104] docs(telemetry): update capture vocabulary --- packages/telemetry/README.i18n.yaml | 4 ++-- packages/telemetry/README.md | 2 +- packages/telemetry/README.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/telemetry/README.i18n.yaml b/packages/telemetry/README.i18n.yaml index cd3be8d155..fba4bd4339 100644 --- a/packages/telemetry/README.i18n.yaml +++ b/packages/telemetry/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/telemetry/README.md -README.md: 0adf140a19bd6ab19c4d4139d4ebdae941c0d1b0 -README.zh.md: 57988732e36d105ebcc48adcdab9344a6cccb525 +README.md: ddac6c6cdc5a7326190283fbe232b6985deb4927 +README.zh.md: 6863ca56d4f63fccb60eb3c92acd1e70b25e27dd diff --git a/packages/telemetry/README.md b/packages/telemetry/README.md index 0adf140a19..ddac6c6cdc 100644 --- a/packages/telemetry/README.md +++ b/packages/telemetry/README.md @@ -6,5 +6,5 @@ Outbound session reporting: the telemetry seam plus its OpenTelemetry backend. T | Package | Role | |---|---| -| [`@deepseek-ai/dsh-session-telemetry`](session-telemetry/) | The seam: capture points, projection, redaction, immediate or held handoff, cursor, ops signals, and the minimal backend contract (`emit`/`flush?`/`shutdown`). | +| [`@deepseek-ai/dsh-session-telemetry`](session-telemetry/) | The seam: capture points, projection, redaction, live or on-demand capture, cursor, ops signals, and the minimal backend contract (`emit`/`flush?`/`shutdown`). | | [`@deepseek-ai/dsh-session-telemetry-otel`](session-telemetry-otel/) | The backend a deployment loads: `FULL`, `FEEDBACK_ONLY`, or `DISABLED` policy around the OTel JS SDK log pipeline. | diff --git a/packages/telemetry/README.zh.md b/packages/telemetry/README.zh.md index 57988732e3..6863ca56d4 100644 --- a/packages/telemetry/README.zh.md +++ b/packages/telemetry/README.zh.md @@ -6,5 +6,5 @@ | 包(package) | 职责 | |---|---| -| [`@deepseek-ai/dsh-session-telemetry`](session-telemetry/) | seam 本体:捕获点、投影、脱敏、即时或暂存交接、游标、运维信号,以及最小后端契约(`emit`/`flush?`/`shutdown`)。 | +| [`@deepseek-ai/dsh-session-telemetry`](session-telemetry/) | seam 本体:捕获点、投影、脱敏、实时或按需捕获、游标、运维信号,以及最小后端契约(`emit`/`flush?`/`shutdown`)。 | | [`@deepseek-ai/dsh-session-telemetry-otel`](session-telemetry-otel/) | 部署方要加载的后端:围绕 OTel JS SDK 日志流水线实施 `FULL`、`FEEDBACK_ONLY` 或 `DISABLED` 策略。 | From b10368a8d53d8cafb1ee991c4b1b9519b8271403 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Thu, 6 Aug 2026 15:34:36 +0800 Subject: [PATCH 021/104] refactor(telemetry): strongly type sharing mode --- ...feedback-gated-session-telemetry.i18n.yaml | 4 +-- ...-08-05-feedback-gated-session-telemetry.md | 2 +- ...-05-feedback-gated-session-telemetry.zh.md | 2 +- docs/config-catalog.md | 8 ++++-- .../tests/gen-config-catalog.spec.ts | 23 ++++++++++++++++ .../session-telemetry-otel/README.i18n.yaml | 4 +-- .../session-telemetry-otel/README.md | 2 ++ .../session-telemetry-otel/README.zh.md | 2 ++ .../session-telemetry-otel/src/index.ts | 26 ++++++++++++------- .../session-telemetry-otel/tests/otel.spec.ts | 18 ++++++++----- scripts/gen-config-catalog.ts | 9 ++++--- 11 files changed, 73 insertions(+), 27 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml index 7909316acd..331b2e97c6 100644 --- a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-feedback-gated-session-telemetry.md -2026-08-05-feedback-gated-session-telemetry.md: 25cc17f75629f72d7351eb0537d72b700c84411f -2026-08-05-feedback-gated-session-telemetry.zh.md: b0e84e60e27fa20f66113c11db62026583a27a19 +2026-08-05-feedback-gated-session-telemetry.md: 00a8f23fa6bf69f10277ad0d9f2513a0df73de16 +2026-08-05-feedback-gated-session-telemetry.zh.md: 888ce48abe7a5ce2212c152730f8203f5747ef41 diff --git a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md index 25cc17f756..00a8f23fa6 100644 --- a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md +++ b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md @@ -10,7 +10,7 @@ Session telemetry originally has one mounted behavior: every accepted record ent ## Decision -`@deepseek-ai/dsh-session-telemetry-otel` exposes three uppercase `mode` values: +`@deepseek-ai/dsh-session-telemetry-otel` exposes the string-valued `TelemetryMode` enum to TypeScript callers and accepts the same three uppercase `mode` values in serialized configuration: - `FULL` is the default and preserves immediate delivery to the configured OTel pipeline. - `FEEDBACK_ONLY` reads the canonical session log when `feedback/record` is appended and hands over the unreleased prefix through that exact event. Records appended after that boundary remain local until another feedback event. diff --git a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md index b0e84e60e2..888ce48abe 100644 --- a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -`@deepseek-ai/dsh-session-telemetry-otel` 公开三个大写的 `mode` 值: +`@deepseek-ai/dsh-session-telemetry-otel` 向 TypeScript 调用方公开以字符串为值的 `TelemetryMode` 枚举,并在序列化配置中接受相同的三个大写 `mode` 值: - `FULL` 是默认值,保留向已配置 OTel 流水线的即时投递。 - `FEEDBACK_ONLY` 在追加 `feedback/record` 时读取权威会话日志,并交接截至该事件的未释放前缀。该边界后追加的记录会留在本地,直到另一个反馈事件。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a4ddedab35..a2f82d2bec 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1184,12 +1184,16 @@ export interface Config { } /** Session-sharing policy selected by {@link Config.mode}. */ -export type TelemetryMode = typeof TELEMETRY_MODES[number] +export enum TelemetryMode { + FULL = 'FULL', + FEEDBACK_ONLY = 'FEEDBACK_ONLY', + DISABLED = 'DISABLED', +} ``` Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) · `OTLPExporterNodeConfigBase` (`@opentelemetry/otlp-exporter-base`) -Source: [`packages/telemetry/session-telemetry-otel/src/index.ts:55`](../packages/telemetry/session-telemetry-otel/src/index.ts) +Source: [`packages/telemetry/session-telemetry-otel/src/index.ts:63`](../packages/telemetry/session-telemetry-otel/src/index.ts) ## `@deepseek-ai/dsh-session-title` diff --git a/packages/examples/agent-spine-demo/tests/gen-config-catalog.spec.ts b/packages/examples/agent-spine-demo/tests/gen-config-catalog.spec.ts index 65a6f6b9ff..ae4966fd12 100644 --- a/packages/examples/agent-spine-demo/tests/gen-config-catalog.spec.ts +++ b/packages/examples/agent-spine-demo/tests/gen-config-catalog.spec.ts @@ -160,6 +160,29 @@ export function apply(ctx: Context, config: Config): void {} expect(entries[0]?.refs).toEqual([{ alias: 'Remote', imported: 'Remote', specifier: '@fix/dep' }]) }) + it('pastes an enum referenced by the config type', () => { + const entries = collectConfigCatalog(make({ + 'src/index.ts': `import type { Context } from 'cordis' +/** Fixture mode. */ +export enum Mode { + A = 'a', + B = 'b', +} +/** Fixture config. */ +export interface Config { + /** The mode. */ + mode?: Mode +} +/** Load. */ +export function apply(ctx: Context, config: Config): void {} +`, + })) + expect(entries[0]?.pastes?.map(p => p.text)).toEqual([ + '/** Fixture config. */\nexport interface Config {\n /** The mode. */\n mode?: Mode\n}', + "/** Fixture mode. */\nexport enum Mode {\n A = 'a',\n B = 'b',\n}", + ]) + }) + it('hard-errors on a referenced type name that resolves nowhere', () => { expect(() => collectConfigCatalog(make({ 'src/index.ts': `import type { Context } from 'cordis' diff --git a/packages/telemetry/session-telemetry-otel/README.i18n.yaml b/packages/telemetry/session-telemetry-otel/README.i18n.yaml index 84e2447fd8..5a6bd9bc9d 100644 --- a/packages/telemetry/session-telemetry-otel/README.i18n.yaml +++ b/packages/telemetry/session-telemetry-otel/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/telemetry/session-telemetry-otel/README.md -README.md: 7fc5572614a5bdba312ba97b52606032ef8f5394 -README.zh.md: 3160b67c8225fb87d5e7be2e43453ef40496fba9 +README.md: 01d803236329afbe65e2d92960928441aaff301c +README.zh.md: 8adf4a3c11b95dc302f8afd0dc79e99433e50f22 diff --git a/packages/telemetry/session-telemetry-otel/README.md b/packages/telemetry/session-telemetry-otel/README.md index 7fc5572614..01d8032363 100644 --- a/packages/telemetry/session-telemetry-otel/README.md +++ b/packages/telemetry/session-telemetry-otel/README.md @@ -24,6 +24,8 @@ The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — th | `FEEDBACK_ONLY` | Each `feedback/record` replays, projects, and redacts the canonical session-log suffix through that event. Later records wait for another feedback event and remain local if none arrives. | | `DISABLED` | No coordinator, provider, processor, or exporter is constructed. No telemetry record leaves the process. A `feedback/record` logs `session telemetry is DISABLED; nothing will be shared and this feedback remains local`; the event remains in the local session log. | +Programmatic TypeScript configuration uses the exported `TelemetryMode` enum (`TelemetryMode.FULL`, `TelemetryMode.FEEDBACK_ONLY`, or `TelemetryMode.DISABLED`); raw string literals are not assignable. Serialized Cordis configuration continues to use the string values shown above. + `exporter.url` is required in `FULL` and `FEEDBACK_ONLY`, has no default, and must parse as `http(s)`; it is optional and unused in `DISABLED`. Uploading modes also reject a non-positive-integer `processor.maxExportBatchSize`, which the SDK accepts but then hangs on at shutdown. Everything else is the SDK's option shape, owned and documented by the SDK, and both blocks pass through whole: every `OTLPExporterNodeConfigBase` field (`headers`, `timeoutMillis`, `compression`, `keepAlive`, …) reaches the exporter, and batching, export cadence (`scheduledDelayMillis`), retry, queue bounds, and loss policy under sustained failure are the SDK's documented behavior, tuned through the `processor` passthrough. The backend deliberately implements no `flush()`: the batch processor is the only flusher in the process, which is what makes `shutdown()`'s drain complete. ## What leaves the machine diff --git a/packages/telemetry/session-telemetry-otel/README.zh.md b/packages/telemetry/session-telemetry-otel/README.zh.md index 3160b67c82..8adf4a3c11 100644 --- a/packages/telemetry/session-telemetry-otel/README.zh.md +++ b/packages/telemetry/session-telemetry-otel/README.zh.md @@ -24,6 +24,8 @@ | `FEEDBACK_ONLY` | 每个 `feedback/record` 都会回放权威会话日志中截至该事件的后缀,并进行投影与脱敏。后续记录等待下一个反馈事件;如果没有后续反馈,则留在本地。 | | `DISABLED` | 不构造协调器、提供方、处理器或导出器。没有遥测记录会离开进程。`feedback/record` 会记录 `session telemetry is DISABLED; nothing will be shared and this feedback remains local`;该事件留在本地会话日志中。 | +程序化 TypeScript 配置使用导出的 `TelemetryMode` 枚举(`TelemetryMode.FULL`、`TelemetryMode.FEEDBACK_ONLY` 或 `TelemetryMode.DISABLED`);原始字符串字面量不可赋值。序列化后的 Cordis 配置继续使用上表所示的字符串值。 + `exporter.url` 在 `FULL` 与 `FEEDBACK_ONLY` 中必填,无默认值,且必须能解析为 `http(s)`;在 `DISABLED` 中可省略且不使用。上传模式也会拒绝不是正整数的 `processor.maxExportBatchSize`,SDK 虽会接受该值,但随后会在关闭时挂起。其余全部是 SDK 自己的选项形态,由 SDK 拥有并在 SDK 文档中说明,两个配置块都整体透传(passthrough):`OTLPExporterNodeConfigBase` 的每个字段(`headers`、`timeoutMillis`、`compression`、`keepAlive` 等)都会到达导出器;批处理、导出节奏(`scheduledDelayMillis`)、重试、队列上限,以及持续失败下的丢失策略,都是 SDK 的文档化行为,经 `processor` 透传调优。该后端刻意不实现 `flush()`:批处理器是进程内唯一执行 flush 的组件,`shutdown()` 的排空正因如此才是完整的。 ## 哪些数据会离开本机 diff --git a/packages/telemetry/session-telemetry-otel/src/index.ts b/packages/telemetry/session-telemetry-otel/src/index.ts index 908f0f90fb..95991cb932 100644 --- a/packages/telemetry/session-telemetry-otel/src/index.ts +++ b/packages/telemetry/session-telemetry-otel/src/index.ts @@ -39,11 +39,19 @@ import { resourceFromAttributes } from '@opentelemetry/resources' // version (same pattern as dsh-llm's attribution identity). const { version } = createRequire(import.meta.url)('../package.json') as { version: string } -/** Supported session-sharing policies for the OTel backend. */ -export const TELEMETRY_MODES = ['FULL', 'FEEDBACK_ONLY', 'DISABLED'] as const - /** Session-sharing policy selected by {@link Config.mode}. */ -export type TelemetryMode = typeof TELEMETRY_MODES[number] +export enum TelemetryMode { + FULL = 'FULL', + FEEDBACK_ONLY = 'FEEDBACK_ONLY', + DISABLED = 'DISABLED', +} + +/** Supported session-sharing policies for runtime configuration validation. */ +export const TELEMETRY_MODES = [ + TelemetryMode.FULL, + TelemetryMode.FEEDBACK_ONLY, + TelemetryMode.DISABLED, +] as const const DISABLED_FEEDBACK_WARNING = 'session telemetry is DISABLED; nothing will be shared and this feedback remains local' @@ -81,7 +89,7 @@ export interface Config { * axiom (and silently drop every field not re-declared). */ export const Config: z<Config> = z.object({ - mode: z.union(TELEMETRY_MODES).default('FULL'), + mode: z.union(TELEMETRY_MODES).default(TelemetryMode.FULL), exporter: z.any(), processor: z.any(), }) @@ -109,8 +117,8 @@ export class TelemetryOtel extends Telemetry { constructor(ctx: Context, config: Config) { super(ctx) - const mode = config.mode ?? 'FULL' - if (mode === 'DISABLED') { + const mode = config.mode ?? TelemetryMode.FULL + if (mode === TelemetryMode.DISABLED) { this.provider = undefined this.ledger = undefined this.ops = undefined @@ -162,9 +170,9 @@ export class TelemetryOtel extends Telemetry { }) this.ledger = this.provider.getLogger('@deepseek-ai/dsh-session-telemetry-otel', version) this.ops = this.provider.getLogger('@deepseek-ai/dsh-session-telemetry-otel/ops', version) - const capture: TelemetryCapture = mode === 'FULL' ? 'live' : 'on-demand' + const capture: TelemetryCapture = mode === TelemetryMode.FULL ? 'live' : 'on-demand' const coordinator = new TelemetryCoordinator(ctx, this, capture) - if (mode === 'FEEDBACK_ONLY') { + if (mode === TelemetryMode.FEEDBACK_ONLY) { // Session.append commits before publishing `session/event`, so the // canonical log already includes this feedback record when replay begins. ctx.on('session/event', (session, event) => { diff --git a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts index 9b7e4119b7..26118e4672 100644 --- a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts +++ b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts @@ -5,7 +5,7 @@ * for the default-exported Service class. */ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest' import { createServer, type Server } from 'node:http' import { once } from 'node:events' import { gunzipSync } from 'node:zlib' @@ -13,7 +13,7 @@ import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import { recordFeedback } from '@deepseek-ai/dsh-command-feedback' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import TelemetryOtel, { Config } from '../src/index.ts' +import TelemetryOtel, { Config, TelemetryMode } from '../src/index.ts' interface Capture { headers: import('node:http').IncomingHttpHeaders @@ -211,7 +211,7 @@ describe('TelemetryOtel wire', () => { const ctx = new Context() await ctx.plugin(SessionStore) const fiber = await ctx.plugin(TelemetryOtel, { - mode: 'FEEDBACK_ONLY', + mode: TelemetryMode.FEEDBACK_ONLY, exporter: { url }, }) const session = ctx.sessions.create(SessionId('feedback-only'), { meta: {} }) @@ -236,7 +236,7 @@ describe('TelemetryOtel wire', () => { const ctx = new Context() await ctx.plugin(SessionStore) const fiber = await ctx.plugin(TelemetryOtel, { - mode: 'FEEDBACK_ONLY', + mode: TelemetryMode.FEEDBACK_ONLY, exporter: { url }, }) const session = ctx.sessions.create(SessionId('no-feedback'), { meta: {} }) @@ -249,7 +249,7 @@ describe('TelemetryOtel wire', () => { const ctx = new Context() await ctx.plugin(SessionStore) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) - const fiber = await ctx.plugin(TelemetryOtel, { mode: 'DISABLED' }) + const fiber = await ctx.plugin(TelemetryOtel, { mode: TelemetryMode.DISABLED }) const session = ctx.sessions.create(SessionId('disabled'), { meta: {} }) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) recordFeedback(session, 'local report') @@ -284,12 +284,18 @@ describe('TelemetryOtel wire', () => { }) describe('TelemetryOtel config fails loud', () => { + it('exposes modes through the nominal enum', () => { + expectTypeOf<Config['mode']>().toEqualTypeOf<TelemetryMode | undefined>() + expectTypeOf<'FULL'>().not.toExtend<TelemetryMode>() + expectTypeOf<TelemetryMode.FULL>().toExtend<TelemetryMode>() + }) + it.each([ [{}, /exporter\.url is required/], [{ exporter: { url: '' } }, /exporter\.url is required/], [{ exporter: { url: 'not a url' } }, /not a valid URL/], [{ exporter: { url: 'ftp://collector' } }, /must be http\(s\)/], - [{ mode: 'FEEDBACK_ONLY' }, /exporter\.url is required/], + [{ mode: TelemetryMode.FEEDBACK_ONLY }, /exporter\.url is required/], [{ mode: 'INVALID' }, /INVALID/], // The SDK accepts a non-positive batch size but its shutdown drain then // splices empty batches forever — dispose would hang, so reject at load. diff --git a/scripts/gen-config-catalog.ts b/scripts/gen-config-catalog.ts index b4c20d596a..920688df6d 100644 --- a/scripts/gen-config-catalog.ts +++ b/scripts/gen-config-catalog.ts @@ -126,12 +126,13 @@ function loadFile(abs: string, rel: string, cache: Map<string, FileCtx>): FileCt } /** A type declaration a paste can contain. */ -type TypeDecl = ts.InterfaceDeclaration | ts.TypeAliasDeclaration +type TypeDecl = ts.InterfaceDeclaration | ts.TypeAliasDeclaration | ts.EnumDeclaration -/** Find an interface/type-alias declaration by name in a file, or null. */ +/** Find a pasteable type declaration by name in a file, or null. */ function findTypeDecl(ctx: FileCtx, name: string): TypeDecl | null { for (const stmt of ctx.sf.statements) { - if ((ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt)) && stmt.name.text === name) return stmt + if ((ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt) || ts.isEnumDeclaration(stmt)) + && stmt.name.text === name) return stmt } return null } @@ -207,7 +208,7 @@ function checkMemberDocs(ctx: FileCtx, decl: TypeDecl, violations: string[]): vo else ts.forEachChild(type, (n) => { walkNested(n, path) }) } if (ts.isInterfaceDeclaration(decl)) walkMembers(decl.members, decl.name.text) - else walkNested(decl.type, decl.name.text) + else if (ts.isTypeAliasDeclaration(decl)) walkNested(decl.type, decl.name.text) } /** Cross-file resolution context for the schema-path check. */ From c92a1da8135829d86e719e7defdb5f591601e81f Mon Sep 17 00:00:00 2001 From: Jiaying Ding <silver.ding@deepseek.com> Date: Thu, 6 Aug 2026 16:10:18 +0800 Subject: [PATCH 022/104] fix(ui): update hero headline copy --- packages/client/ui-conversation/src/client/locales.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 9ba5ed3876..eec25939b3 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -44,7 +44,7 @@ export const zh = { 'access.confirm.acknowledge': '我已了解风险,并愿意继续', 'access.confirm.cancel': '取消', 'access.confirm.enable': '启用 Full access', - 'hero.headline': '开始构建吧', + 'hero.headline': '探索未知之境', 'hero.preview': '预览版', 'hero.chooseWorkspace': '选择工作区', 'session.hierarchy': '会话层级', @@ -184,7 +184,7 @@ export const en = { 'access.confirm.acknowledge': 'I understand the risks and want to continue', 'access.confirm.cancel': 'Cancel', 'access.confirm.enable': 'Enable Full access', - 'hero.headline': 'Let\'s start building', + 'hero.headline': 'Into the unknown', 'hero.preview': 'Preview', 'hero.chooseWorkspace': 'Choose workspace', 'session.hierarchy': 'Session hierarchy', From 9db4372af80230b9c4be533d068bb07005eddd6b Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Thu, 6 Aug 2026 16:15:58 +0800 Subject: [PATCH 023/104] fix: align feedback package publication files --- packages/feedback/command-feedback/package.json | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/feedback/command-feedback/package.json b/packages/feedback/command-feedback/package.json index 25bc8446c3..535c438a63 100644 --- a/packages/feedback/command-feedback/package.json +++ b/packages/feedback/command-feedback/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { From c6b581b4e8069de5cc5594427274f0468b58aaf4 Mon Sep 17 00:00:00 2001 From: Jiaying Ding <silver.ding@deepseek.com> Date: Thu, 6 Aug 2026 16:26:53 +0800 Subject: [PATCH 024/104] test(ui): update hero headline expectations --- .../client/ui-conversation/tests/skeleton.spec.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index b2828bcc80..858cbe5b7b 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -218,7 +218,7 @@ function mount( describe('Hero chrome', () => { it('renders the English preview badge through the hero locale seat', () => { const view = render(<HeroShell t={makeTranslate(en, commonEn)} />) - expect(view.getByText('Let\'s start building')).toBeTruthy() + expect(view.getByText('Into the unknown')).toBeTruthy() expect(view.getByText('Preview')).toBeTruthy() }) }) @@ -282,7 +282,7 @@ describe('ConversationRoot resident composer', () => { const header = b.view.container.querySelector('header') expect(host).not.toBeNull() expect(header?.getAttribute('aria-hidden')).toBe('true') - expect(b.view.getByText('开始构建吧')).toBeTruthy() + expect(b.view.getByText('探索未知之境')).toBeTruthy() expect(b.view.getByText('预览版')).toBeTruthy() expect(b.view.queryByTestId('view-chat')).toBeNull() // The same machine-backed textarea is live in the hero, and the @@ -306,7 +306,7 @@ describe('ConversationRoot resident composer', () => { const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true, openState: 'loading' })) const root = b.view.container.querySelector('[data-phase]') expect(root?.getAttribute('data-phase')).toBe('settling') - expect(b.view.queryByText('开始构建吧')).toBeNull() + expect(b.view.queryByText('探索未知之境')).toBeNull() }) it('settling phase: a session the list has no row for settles conservatively', () => { @@ -331,7 +331,7 @@ describe('ConversationRoot resident composer', () => { // blank the column for the history round-trip. const root = b.view.container.querySelector('[data-phase]') expect(root?.getAttribute('data-phase')).toBe('hero') - expect(b.view.getByText('开始构建吧')).toBeTruthy() + expect(b.view.getByText('探索未知之境')).toBeTruthy() expect(b.view.getByRole('textbox')).toBeTruthy() }) @@ -349,7 +349,7 @@ describe('ConversationRoot resident composer', () => { expect(after.value).toBe('kept across flip') expect(b.chat.store.getSnapshot().draft).toBe('kept across flip') expect(b.view.container.querySelector('[data-conversation-scroll]')?.contains(after)).toBe(true) - expect(b.view.queryByText('开始构建吧')).toBeNull() + expect(b.view.queryByText('探索未知之境')).toBeNull() expect(b.view.getByTestId('view-chat')).toBeTruthy() }) From ccb0842cfcc23ca11a89c136761e355eb0c94741 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Thu, 6 Aug 2026 16:27:27 +0800 Subject: [PATCH 025/104] fix(telemetry): fail closed outside full mode --- ...feedback-gated-session-telemetry.i18n.yaml | 4 +- ...-08-05-feedback-gated-session-telemetry.md | 6 +- ...-05-feedback-gated-session-telemetry.zh.md | 6 +- docs/config-catalog.md | 2 +- .../session-telemetry-otel/README.i18n.yaml | 4 +- .../session-telemetry-otel/README.md | 2 + .../session-telemetry-otel/README.zh.md | 2 + .../session-telemetry-otel/src/index.ts | 91 +++++++++++++------ .../session-telemetry-otel/tests/otel.spec.ts | 64 ++++++++++++- 9 files changed, 139 insertions(+), 42 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml index 331b2e97c6..4255886987 100644 --- a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-feedback-gated-session-telemetry.md -2026-08-05-feedback-gated-session-telemetry.md: 00a8f23fa6bf69f10277ad0d9f2513a0df73de16 -2026-08-05-feedback-gated-session-telemetry.zh.md: 888ce48abe7a5ce2212c152730f8203f5747ef41 +2026-08-05-feedback-gated-session-telemetry.md: 7d923a7e4cf61e8d1119187564b87e4cbb2065b7 +2026-08-05-feedback-gated-session-telemetry.zh.md: 2862162c0c36e5194846c7e1c7bbc24230ce90aa diff --git a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md index 00a8f23fa6..7d923a7e4c 100644 --- a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md +++ b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md @@ -18,7 +18,7 @@ Session telemetry originally has one mounted behavior: every accepted record ent The generic telemetry coordinator owns `live` and `on-demand` capture. Live capture projects, clones, redacts, and hands each event to the backend on the session firehose. On-demand capture registers no continuous capture listeners; `captureSession(session, throughSeq)` reads the canonical log from the handoff cursor through an inclusive boundary, then projects, clones, redacts, and hands over that prefix. The cursor advances only for handed-over records. The [buffer-free replay decision](../simplification/2026-08-06-buffer-free-feedback-telemetry.md) owns why the on-demand path uses the canonical log instead of copied records. -The OTel feedback listener passes the feedback event's sequence to `captureSession()`. `Session.append` commits the event before publishing `session/event`, so replay includes that feedback but cannot extend past its boundary. `exporter.url` is required in `FULL` and `FEEDBACK_ONLY`; `DISABLED` does not validate or use exporter configuration. +Mode resolution is a closed, fail-before-setup check: an unknown direct-construction value fails before transport configuration is read. Only `FULL` exposes the public service's `emit()` path to the SDK pipeline. `FEEDBACK_ONLY` gives its on-demand coordinator a private backend capability; its listener passes an event to `captureSession()` only when the exact `feedback/record` object is already stored at `session.events[event.seq]`. `Session.append` commits that object before publishing `session/event`, so replay includes the feedback but cannot extend past its boundary. `DISABLED` creates neither the capability nor the SDK pipeline and does not inspect exporter configuration. ## Alternatives considered @@ -26,8 +26,10 @@ The OTel feedback listener passes the feedback event's sequence to `captureSessi **Retain capture-time redacted records until feedback.** Rejected because it duplicates an unbounded session prefix even though the canonical log already owns the events. It preserves capture-time redaction policy and operational records, but those properties do not justify the memory cost for a mode defined as uploading the session log after feedback. +**Temporarily allow public `emit()` calls during feedback replay.** Rejected because a redaction listener or another reentrant caller could enqueue an unrelated record while the flag was open. A private backend capability makes authorization structural and keeps the public service closed throughout replay. + **Use an unmounted plugin as the disabled state.** That remains the silent opt-out, but it cannot warn when feedback is recorded. The explicit disabled mode lets a deployment keep one configuration shape and communicate that the local feedback did not leave the process. ## Consequences -`FULL` remains source- and wire-compatible with the original default. `FEEDBACK_ONLY` adds no telemetry-owned per-event buffer before feedback; a crash before feedback uploads nothing from that prefix. Replay applies the redaction policy mounted when feedback is recorded and excludes operational records that do not exist in the canonical log. Feedback-only streams therefore carry neither `agent-error` nor `shutdown` records, and shutdown absence is not a crash signal. Each later feedback captures the suffix accumulated since the previous boundary. `DISABLED` can omit `exporter.url`, does no reporting work, and keeps feedback only in the canonical session log. +`FULL` remains source- and wire-compatible with the original default. `FEEDBACK_ONLY` adds no telemetry-owned per-event buffer before feedback; direct service calls and non-canonical feedback events upload nothing, and a crash before feedback uploads nothing from that prefix. Replay applies the redaction policy mounted when feedback is recorded and excludes operational records that do not exist in the canonical log. Feedback-only streams therefore carry neither `agent-error` nor `shutdown` records, and shutdown absence is not a crash signal. Each later feedback captures the suffix accumulated since the previous boundary. `DISABLED` can omit `exporter.url`, does no reporting work, and keeps feedback only in the canonical session log. diff --git a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md index 888ce48abe..2862162c0c 100644 --- a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md @@ -18,7 +18,7 @@ Status: implemented 通用遥测协调器拥有 `live` 与 `on-demand` 捕获。实时捕获在会话 firehose 上投影、深拷贝、脱敏每个事件,并将其交给后端。按需捕获不注册持续捕获监听器;`captureSession(session, throughSeq)` 从 handoff 游标起读取权威日志,直至含边界的指定序列号,然后投影、深拷贝、脱敏并交接该前缀。游标只为已交接记录推进。[无缓冲回放决策](../simplification/2026-08-06-buffer-free-feedback-telemetry.md)说明了按需路径为何使用权威日志而非记录副本。 -OTel 反馈监听器把反馈事件的序列号传给 `captureSession()`。`Session.append` 在发布 `session/event` 前已提交该事件,因此回放会包含该反馈,但不会超过其边界。`exporter.url` 在 `FULL` 与 `FEEDBACK_ONLY` 中必填;`DISABLED` 不校验也不使用导出器配置。 +模式解析采用封闭式检查,并在设置前失败:通过直接构造传入未知值时,会在读取传输配置前失败。只有 `FULL` 向 SDK 流水线开放公共服务的 `emit()` 路径。`FEEDBACK_ONLY` 向其按需协调器提供私有后端能力;其监听器向 `captureSession()` 传递事件的唯一条件,是该事件与那个 `feedback/record` 对象身份完全相同,且该对象已存储于 `session.events[event.seq]`。`Session.append` 在发布 `session/event` 前已提交该对象,因此回放包含该反馈,但不会越过其边界。`DISABLED` 既不创建该能力,也不创建 SDK 流水线,并且不检查导出器配置。 ## 考虑过的替代方案 @@ -26,8 +26,10 @@ OTel 反馈监听器把反馈事件的序列号传给 `captureSession()`。`Sess **反馈前保留捕获时已脱敏记录。** 已否决,因为权威日志已拥有这些事件,该方案仍会复制无上限的会话前缀。它能保留捕获时的脱敏策略与运维记录,但对于一个定义为「反馈后上传会话日志」的模式,这些性质不足以证明该内存成本合理。 +**在反馈回放期间临时允许公开 `emit()` 调用。** 已否决,因为在标志开启期间,脱敏监听器或另一个可重入调用方可能将无关记录入队。私有后端能力使授权成为结构性保证,并确保公共服务在整个回放过程中保持关闭。 + **以不挂载插件表示禁用状态。** 这仍然是静默退出方式,但无法在记录反馈时输出警告。显式禁用模式让部署方可以保持同一种配置形态,并说明本地反馈未离开进程。 ## 后果 -`FULL` 与原有默认值保持源码及协议兼容。`FEEDBACK_ONLY` 在反馈前不增加遥测自有的逐事件缓冲;反馈前发生崩溃时,该前缀不上传任何内容。回放使用记录反馈时挂载的脱敏策略,并排除权威日志中不存在的运维记录。因此,仅反馈的流既不携带 `agent-error` 记录,也不携带 `shutdown` 记录,而缺少 shutdown 不是崩溃信号。每个后续反馈都会捕获从上一个边界起累积的后缀。`DISABLED` 可省略 `exporter.url`,不执行任何上报工作,并仅在权威会话日志中保留反馈。 +`FULL` 与原有默认值保持源码及协议兼容。`FEEDBACK_ONLY` 在反馈前不增加遥测自有的逐事件缓冲;直接服务调用与非权威反馈事件均不上传任何内容,且反馈前发生崩溃时,该前缀也不上传任何内容。回放使用记录反馈时挂载的脱敏策略,并排除权威日志中不存在的运维记录。因此,仅反馈的流既不携带 `agent-error` 记录,也不携带 `shutdown` 记录,而缺少 shutdown 不是崩溃信号。每个后续反馈都会捕获从上一个边界起累积的后缀。`DISABLED` 可省略 `exporter.url`,不执行任何上报工作,并仅在权威会话日志中保留反馈。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a2f82d2bec..c386ee4e6b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1193,7 +1193,7 @@ export enum TelemetryMode { Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) · `OTLPExporterNodeConfigBase` (`@opentelemetry/otlp-exporter-base`) -Source: [`packages/telemetry/session-telemetry-otel/src/index.ts:63`](../packages/telemetry/session-telemetry-otel/src/index.ts) +Source: [`packages/telemetry/session-telemetry-otel/src/index.ts:83`](../packages/telemetry/session-telemetry-otel/src/index.ts) ## `@deepseek-ai/dsh-session-title` diff --git a/packages/telemetry/session-telemetry-otel/README.i18n.yaml b/packages/telemetry/session-telemetry-otel/README.i18n.yaml index 5a6bd9bc9d..ec7fddf6b4 100644 --- a/packages/telemetry/session-telemetry-otel/README.i18n.yaml +++ b/packages/telemetry/session-telemetry-otel/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/telemetry/session-telemetry-otel/README.md -README.md: 01d803236329afbe65e2d92960928441aaff301c -README.zh.md: 8adf4a3c11b95dc302f8afd0dc79e99433e50f22 +README.md: af177dc86bc30a7b17e34e3c8c3592326b9026f2 +README.zh.md: 9a3ad628bb7d480a1c4cd8346669ad8ebbd6258f diff --git a/packages/telemetry/session-telemetry-otel/README.md b/packages/telemetry/session-telemetry-otel/README.md index 01d8032363..af177dc86b 100644 --- a/packages/telemetry/session-telemetry-otel/README.md +++ b/packages/telemetry/session-telemetry-otel/README.md @@ -26,6 +26,8 @@ The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — th Programmatic TypeScript configuration uses the exported `TelemetryMode` enum (`TelemetryMode.FULL`, `TelemetryMode.FEEDBACK_ONLY`, or `TelemetryMode.DISABLED`); raw string literals are not assignable. Serialized Cordis configuration continues to use the string values shown above. +Upload authorization is positive and fail-closed. An unknown direct-construction mode fails before transport configuration is read. Only `FULL` accepts direct `ctx.telemetry.emit()` calls. `FEEDBACK_ONLY` gives its on-demand coordinator a private backend capability and treats only the exact `feedback/record` object already stored at `session.events[event.seq]` as consent; an independently emitted bus value is ignored. `DISABLED` never constructs the SDK pipeline, even when exporter options are present. + `exporter.url` is required in `FULL` and `FEEDBACK_ONLY`, has no default, and must parse as `http(s)`; it is optional and unused in `DISABLED`. Uploading modes also reject a non-positive-integer `processor.maxExportBatchSize`, which the SDK accepts but then hangs on at shutdown. Everything else is the SDK's option shape, owned and documented by the SDK, and both blocks pass through whole: every `OTLPExporterNodeConfigBase` field (`headers`, `timeoutMillis`, `compression`, `keepAlive`, …) reaches the exporter, and batching, export cadence (`scheduledDelayMillis`), retry, queue bounds, and loss policy under sustained failure are the SDK's documented behavior, tuned through the `processor` passthrough. The backend deliberately implements no `flush()`: the batch processor is the only flusher in the process, which is what makes `shutdown()`'s drain complete. ## What leaves the machine diff --git a/packages/telemetry/session-telemetry-otel/README.zh.md b/packages/telemetry/session-telemetry-otel/README.zh.md index 8adf4a3c11..9a3ad628bb 100644 --- a/packages/telemetry/session-telemetry-otel/README.zh.md +++ b/packages/telemetry/session-telemetry-otel/README.zh.md @@ -26,6 +26,8 @@ 程序化 TypeScript 配置使用导出的 `TelemetryMode` 枚举(`TelemetryMode.FULL`、`TelemetryMode.FEEDBACK_ONLY` 或 `TelemetryMode.DISABLED`);原始字符串字面量不可赋值。序列化后的 Cordis 配置继续使用上表所示的字符串值。 +上传授权采用显式许可,且为 fail-closed。通过直接构造传入未知模式时,会在读取传输配置前失败。只有 `FULL` 接受对 `ctx.telemetry.emit()` 的直接调用。`FEEDBACK_ONLY` 向其按需协调器提供私有后端能力,并且仅在 `feedback/record` 对象已经存储于 `session.events[event.seq]` 且对象身份完全相同时,才将其视为同意;独立发出的总线值会被忽略。即使存在导出器选项,`DISABLED` 也绝不会构造 SDK 流水线。 + `exporter.url` 在 `FULL` 与 `FEEDBACK_ONLY` 中必填,无默认值,且必须能解析为 `http(s)`;在 `DISABLED` 中可省略且不使用。上传模式也会拒绝不是正整数的 `processor.maxExportBatchSize`,SDK 虽会接受该值,但随后会在关闭时挂起。其余全部是 SDK 自己的选项形态,由 SDK 拥有并在 SDK 文档中说明,两个配置块都整体透传(passthrough):`OTLPExporterNodeConfigBase` 的每个字段(`headers`、`timeoutMillis`、`compression`、`keepAlive` 等)都会到达导出器;批处理、导出节奏(`scheduledDelayMillis`)、重试、队列上限,以及持续失败下的丢失策略,都是 SDK 的文档化行为,经 `processor` 透传调优。该后端刻意不实现 `flush()`:批处理器是进程内唯一执行 flush 的组件,`shutdown()` 的排空正因如此才是完整的。 ## 哪些数据会离开本机 diff --git a/packages/telemetry/session-telemetry-otel/src/index.ts b/packages/telemetry/session-telemetry-otel/src/index.ts index 95991cb932..f380d97549 100644 --- a/packages/telemetry/session-telemetry-otel/src/index.ts +++ b/packages/telemetry/session-telemetry-otel/src/index.ts @@ -20,7 +20,7 @@ import type {} from '@deepseek-ai/dsh-command-feedback' import { Telemetry, TelemetryCoordinator, - type TelemetryCapture, + type TelemetryBackend, type TelemetryRecord, type TelemetrySeverity, } from '@deepseek-ai/dsh-session-telemetry' @@ -54,6 +54,26 @@ export const TELEMETRY_MODES = [ ] as const const DISABLED_FEEDBACK_WARNING = 'session telemetry is DISABLED; nothing will be shared and this feedback remains local' +const NON_CANONICAL_FEEDBACK_WARNING = 'session telemetry ignored a feedback event absent from the canonical session log' +const DROP_RECORD: TelemetryBackend['emit'] = () => {} + +/** Resolve the default and reject unknown runtime values before transport setup. */ +function resolveMode(mode: TelemetryMode | undefined): TelemetryMode { + const resolved = mode ?? TelemetryMode.FULL + switch (resolved) { + case TelemetryMode.FULL: + case TelemetryMode.FEEDBACK_ONLY: + case TelemetryMode.DISABLED: + return resolved + default: + return assertNever(resolved) + } +} + +/** Fail closed when direct construction bypasses the runtime config schema. */ +function assertNever(value: never): never { + throw new Error(`session-telemetry-otel: unsupported mode ${JSON.stringify(value)}`) +} /** * Plugin configuration: one sharing policy plus two verbatim SDK option @@ -111,17 +131,15 @@ export class TelemetryOtel extends Telemetry { static inject = ['sessions'] static Config = Config + private readonly directEmit: TelemetryBackend['emit'] private readonly provider: LoggerProvider | undefined - private readonly ledger: Logger | undefined - private readonly ops: Logger | undefined constructor(ctx: Context, config: Config) { + const mode = resolveMode(config.mode) super(ctx) - const mode = config.mode ?? TelemetryMode.FULL if (mode === TelemetryMode.DISABLED) { + this.directEmit = DROP_RECORD this.provider = undefined - this.ledger = undefined - this.ops = undefined ctx.on('session/event', (_session, event) => { if (event.type === 'feedback/record') ctx.logger.warn(DISABLED_FEEDBACK_WARNING) }) @@ -168,37 +186,50 @@ export class TelemetryOtel extends Telemetry { }), ], }) - this.ledger = this.provider.getLogger('@deepseek-ai/dsh-session-telemetry-otel', version) - this.ops = this.provider.getLogger('@deepseek-ai/dsh-session-telemetry-otel/ops', version) - const capture: TelemetryCapture = mode === TelemetryMode.FULL ? 'live' : 'on-demand' - const coordinator = new TelemetryCoordinator(ctx, this, capture) - if (mode === TelemetryMode.FEEDBACK_ONLY) { - // Session.append commits before publishing `session/event`, so the - // canonical log already includes this feedback record when replay begins. - ctx.on('session/event', (session, event) => { - if (event.type === 'feedback/record') coordinator.captureSession(session, event.seq) + const ledger = this.provider.getLogger('@deepseek-ai/dsh-session-telemetry-otel', version) + const ops = this.provider.getLogger('@deepseek-ai/dsh-session-telemetry-otel/ops', version) + const enqueue: TelemetryBackend['emit'] = (record) => { + const logger: Logger = record.channel === 'ops' ? ops : ledger + logger.emit({ + timestamp: record.time, + observedTimestamp: record.time, + ...SEVERITY[record.severity], + // JSON-serializable by the seam's contract (validated at Session.append), + // which is exactly the AnyValue subset. + body: record.body as AnyValue, + attributes: record.attributes, }) } + const backend: TelemetryBackend = { + emit: enqueue, + shutdown: () => this.shutdown(), + } + if (mode === TelemetryMode.FULL) { + this.directEmit = enqueue + new TelemetryCoordinator(ctx, backend, 'live') + return + } + this.directEmit = DROP_RECORD + const coordinator = new TelemetryCoordinator(ctx, backend, 'on-demand') + ctx.on('session/event', (session, event) => { + if (event.type !== 'feedback/record') return + // Consent is the committed record, not an independently emitted bus value. + if (session.events[event.seq] !== event) { + ctx.logger.warn(NON_CANONICAL_FEEDBACK_WARNING) + return + } + coordinator.captureSession(session, event.seq) + }) } /** - * Map one seam record onto the SDK logger for its channel — a synchronous - * enqueue into the batch processor's queue. Direct calls are no-ops in - * `DISABLED`, where no coordinator or SDK pipeline exists. - * @param record - the logical record handed over by the coordinator. + * Hand a direct service record to the SDK only in `FULL`. Direct calls are + * no-ops in `FEEDBACK_ONLY` and `DISABLED`; feedback replay uses a private + * backend capability created only for the canonical feedback listener. + * @param record - the logical record offered directly to the service. */ emit(record: TelemetryRecord): void { - const logger = record.channel === 'ops' ? this.ops : this.ledger - if (logger === undefined) return - logger.emit({ - timestamp: record.time, - observedTimestamp: record.time, - ...SEVERITY[record.severity], - // JSON-serializable by the seam's contract (validated at Session.append), - // which is exactly the AnyValue subset. - body: record.body as AnyValue, - attributes: record.attributes, - }) + this.directEmit(record) } // The seam's optional flush() hint is deliberately NOT implemented. The diff --git a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts index 26118e4672..f7b3a007c9 100644 --- a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts +++ b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts @@ -105,6 +105,13 @@ describe('TelemetryOtel wire', () => { const session = ctx.sessions.create(SessionId('wire'), { meta: { cwd: '/tmp/w' } }) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'boom' } }) + ctx.telemetry.emit({ + channel: 'ledger', + time: Date.now(), + severity: 'info', + attributes: { 'session.id': 'wire', 'event.type': 'manual', 'event.seq': 99 }, + body: { direct: true }, + }) await fiber.dispose() expect(captures.length).toBeGreaterThan(0) @@ -128,6 +135,7 @@ describe('TelemetryOtel wire', () => { const end = ledger.find(r => r.record.attributes?.some(a => a.key === 'event.type' && a.value.stringValue === 'turn/end')) expect(end?.record.severityNumber).toBe(17) expect(end?.record.severityText).toBe('ERROR') + expect(eventTypes(captures)).toContain('manual') expect(ops).toHaveLength(1) expect(ops[0]!.record.attributes).toContainEqual({ key: 'telemetry.op', value: { stringValue: 'shutdown' } }) @@ -214,6 +222,16 @@ describe('TelemetryOtel wire', () => { mode: TelemetryMode.FEEDBACK_ONLY, exporter: { url }, }) + ctx.on('telemetry/record', (_record, next) => { + ctx.telemetry.emit({ + channel: 'ledger', + time: Date.now(), + severity: 'info', + attributes: { 'session.id': 'feedback-only', 'event.type': 'direct-bypass', 'event.seq': 99 }, + body: { mustStayLocal: true }, + }) + return next() + }) const session = ctx.sessions.create(SessionId('feedback-only'), { meta: {} }) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) recordFeedback(session, 'first report') @@ -231,25 +249,48 @@ describe('TelemetryOtel wire', () => { expect(allRecords(captures).some(({ scope }) => scope.endsWith('/ops'))).toBe(false) }) - it('sends no request when feedback-only mode ends without feedback', async () => { + it('ignores direct emits and non-canonical feedback in feedback-only mode', async () => { const { url, captures } = await mockCollector() const ctx = new Context() await ctx.plugin(SessionStore) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) const fiber = await ctx.plugin(TelemetryOtel, { mode: TelemetryMode.FEEDBACK_ONLY, exporter: { url }, }) const session = ctx.sessions.create(SessionId('no-feedback'), { meta: {} }) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + ctx.telemetry.emit({ + channel: 'ledger', + time: Date.now(), + severity: 'info', + attributes: { 'session.id': 'no-feedback', 'event.type': 'direct', 'event.seq': 99 }, + body: { mustStayLocal: true }, + }) + ctx.emit('session/event', session, { + type: 'feedback/record', + seq: session.events.length, + time: Date.now(), + data: { text: 'not committed' }, + }) await fiber.dispose() + + expect(warn).toHaveBeenCalledWith( + 'session telemetry ignored a feedback event absent from the canonical session log', + ) expect(captures).toEqual([]) }) - it('boots disabled without exporter config and warns when feedback stays local', async () => { + it('constructs no disabled transport even when exporter options are present', async () => { + const { url, captures } = await mockCollector() const ctx = new Context() await ctx.plugin(SessionStore) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) - const fiber = await ctx.plugin(TelemetryOtel, { mode: TelemetryMode.DISABLED }) + const fiber = await ctx.plugin(TelemetryOtel, { + mode: TelemetryMode.DISABLED, + exporter: { url }, + processor: { maxExportBatchSize: 0 }, + }) const session = ctx.sessions.create(SessionId('disabled'), { meta: {} }) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) recordFeedback(session, 'local report') @@ -268,6 +309,7 @@ describe('TelemetryOtel wire', () => { await fiber.dispose() recordFeedback(session, 'after disposal') expect(warn).toHaveBeenCalledTimes(1) + expect(captures).toEqual([]) }) it('defaults direct construction to full delivery', async () => { @@ -306,6 +348,22 @@ describe('TelemetryOtel config fails loud', () => { await ctx.plugin(SessionStore) await expect(ctx.plugin(TelemetryOtel, config as Config)).rejects.toThrow(message) }) + + it('rejects an unknown direct mode before reading transport config', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + let exporterRead = false + const config = { + mode: 'INVALID', + get exporter() { + exporterRead = true + throw new Error('transport config was read') + }, + } as unknown as Config + + expect(() => new TelemetryOtel(ctx, config)).toThrow(/unsupported mode "INVALID"/) + expect(exporterRead).toBe(false) + }) }) describe('dsh-session-telemetry-otel real-load-path guard', () => { From 4f595311f793bf2759243aefd31901bfc9c72aac Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Thu, 6 Aug 2026 16:32:46 +0800 Subject: [PATCH 026/104] test: include feedback in Web command catalog snapshot --- .../tests/snapshots/lifecycle-chrome/command-menu.expected.md | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/tests/snapshots/lifecycle-chrome/command-menu.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/command-menu.expected.md index 1da9b9a45e..7b18ab188b 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/command-menu.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/command-menu.expected.md @@ -1,6 +1,7 @@ - listbox "Trigger suggestions": - text: Commands - option "compact Compact older conversation history" [selected] + - option "feedback record feedback about this session" - option "goal set or view the goal for a long-running task" - option "permission Switch the permission preset (sandbox mode + approval policy)" - option "plan Enter or leave plan mode" From a667d2cd64fcc213e97d7c12c2aaf6f3e8c6c0b0 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Thu, 6 Aug 2026 17:21:13 +0800 Subject: [PATCH 027/104] fix(web): address skill row review feedback --- .../2026-08-06-web-skill-tool-row.i18n.yaml | 4 +- .../feature/2026-08-06-web-skill-tool-row.md | 6 +-- .../2026-08-06-web-skill-tool-row.zh.md | 6 +-- packages/client/connection/src/client/api.ts | 2 +- .../client/connection/src/client/fixture.ts | 43 +++++++++------ .../client/connection/src/client/index.ts | 2 +- packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 2 +- packages/client/runtime/README.zh.md | 2 +- .../client/session-history/history-fold.ts | 16 ++++-- .../src/client/sessions/conversation.ts | 6 +-- .../runtime/src/client/sessions/session.ts | 12 +++-- .../src/client/sessions/transcript-adapter.ts | 26 +++++++-- packages/client/runtime/tests/fake-api.ts | 4 +- .../client/runtime/tests/history-fold.spec.ts | 14 +++++ packages/client/runtime/tests/session.spec.ts | 17 ++++++ .../runtime/tests/transcript-adapter.spec.ts | 16 ++++++ .../src/client/chat/ToolRow.tsx | 13 +---- .../client/contract/terminal-card-model.ts | 11 ++-- .../src/client/toolviews/bash-sample.tsx | 6 +-- .../ui-conversation/tests/chat-view.spec.tsx | 4 +- .../client/ui-primitives/src/icons/index.tsx | 7 +++ .../client/ui-primitives/tests/icons.spec.tsx | 4 +- packages/client/ui-skill/README.i18n.yaml | 4 +- packages/client/ui-skill/README.md | 2 +- packages/client/ui-skill/README.zh.md | 2 +- .../client/ui-skill/src/client/SkillRow.tsx | 31 +++++------ packages/client/ui-skill/src/invariant.ts | 7 +-- .../ui-skill/tests/browser-plugin.spec.ts | 44 ++++++++------- .../client/ui-skill/tests/skill-row.spec.tsx | 6 +-- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 54 +++++++++++++++---- packages/host/apiproxy/src/api/index.ts | 2 +- .../host/apiproxy/src/api/sessions.schema.ts | 12 ++++- packages/host/apiproxy/src/api/sessions.ts | 18 +++++-- .../apiproxy/tests/api-proxy-view.spec.ts | 41 ++++++++++++++ .../host/apiproxy/tests/rpc-schemas.spec.ts | 10 +++- 39 files changed, 326 insertions(+), 142 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.i18n.yaml index 8186444a8d..237338a7e6 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-web-skill-tool-row.md -2026-08-06-web-skill-tool-row.md: b1d76c411d7ccc839616ddcce9fee18716489bf5 -2026-08-06-web-skill-tool-row.zh.md: c16a9b84d75c641b0fdd8778ff56c331c2c81546 +2026-08-06-web-skill-tool-row.md: bebcf658de33d133ffea8eb190fb4e8e63bf82ff +2026-08-06-web-skill-tool-row.zh.md: 9377829aab1cb6b347cb837dafe7e7e4afb63868 diff --git a/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.md b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.md index b1d76c411d..bebcf658de 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.md +++ b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.md @@ -14,16 +14,16 @@ The Web transcript renders `skill` calls through the generic fallback row, so a The collapsed row uses a 16-pixel document-and-sparkle glyph and the Bash row's neutral hierarchy: tertiary glyph, secondary `Skill` title, caption separator, and tertiary skill name. Running, failed, and interrupted calls retain the transcript's shimmer, error dot and first-line summary, and warning dot semantics. A settled call expands through the whole summary row into a 260-pixel bounded `Instructions` card containing the exact durable result text; the existing trajectory `Inspect` handoff remains available below the card. -The row derives every visible value from the logged call/result slice. It reads the skill name from the recorded `name` argument and the instructions from durable result content, and never joins the current skill catalog for descriptions or provider metadata. The existing ACP `skill-load` recording is seeded through the real Web persistence and composition path for a keyless interaction and accessibility snapshot. +The row derives every visible value from the logged call/result slice. It reads the skill name from the recorded `name` argument and the instructions from durable result content, and never joins the current skill catalog for descriptions or provider metadata. Because a history page can contain a `tool/result` after its `tool/call` fell outside the window, the generic `HistoryEntry` envelope now carries the paired call's name, exact arguments JSON, and event time on result entries. The Host derives this transient annotation and the result render intent from the complete log; the runtime prefers an in-window call and otherwise materializes the same `ToolResultNode.call` and `callTime` from the annotation. An orphan result still has `call: null`, and a call-side render intent remains unavailable when its event is outside the page. The existing ACP `skill-load` recording is seeded through the real Web persistence and composition path for a keyless interaction and accessibility snapshot. ## Alternatives considered - Keep the generic tool row and add only a `skill` color selector in `ui-conversation`. This leaves the redundant input envelope and generic expanded body in place, and makes the conversation package own a domain-specific visual rule. -- Add a new `skill` value to the host tool render-intent union. The keyed client slot already identifies this tool without changing the wire contract, so a new cross-boundary presentation value adds protocol and snapshot surface without enabling another consumer. +- Add a new `skill` value to the host tool render-intent union. The keyed client slot already identifies this tool; the cross-page fix belongs to the generic history pairing envelope used by every tool rather than a skill-specific presentation value. - Export the conversation package's private `ToolRow` component for reuse. Client packages intentionally expose contracts rather than cross-package components; exporting it would couple independent feature packages to conversation implementation details. ## Consequences `ui-skill` now depends on the public conversation toolview contract, locale and primitive packages, and React in addition to its reference-source dependencies. It owns a small copy of the disclosure-row chrome, so future global interaction changes must update this registrant alongside the Bash sample and conversation rows. -Cold replay stays deterministic when the installed skill catalog changes, and the transcript remains compact until instructions are explicitly expanded. The dedicated card intentionally shows the tool's complete framed output rather than extracting only `<skill_instructions>`, preserving exactly what reached the model and avoiding a second parser for the skill result format. +Cold replay stays deterministic across pagination and when the installed skill catalog changes, and the transcript remains compact until instructions are explicitly expanded. The generic pairing annotation also prevents other keyed tool rows and result presenters from changing identity at a page boundary without persisting duplicate data. The dedicated card intentionally shows the tool's complete framed output rather than extracting only `<skill_instructions>`, preserving exactly what reached the model and avoiding a second parser for the skill result format. diff --git a/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.zh.md b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.zh.md index c16a9b84d7..9377829aab 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.zh.md +++ b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.zh.md @@ -14,16 +14,16 @@ Web transcript(文本记录)通过通用后备行渲染 `skill` 调用,使 收起的行使用 16 像素的文档与闪光组合图标,并沿用 Bash 行的中性色层级:图标采用三级色,`Skill` 标题采用二级色,分隔符采用 caption 色,skill 名称采用三级色。运行、失败和中断调用分别沿用 transcript 的扫光、错误状态点加首行摘要,以及警告状态点语义。已结算调用可以通过整个摘要行展开一个高度上限为 260 像素的 `Instructions` 卡片,其中原样呈现持久化结果文本;用于跳转至 trajectory 的现有 `Inspect` 入口仍保留在卡片下方。 -该行的所有可见值均派生自已记录的调用/结果片段。skill 名称来自已记录的 `name` 参数,指令来自持久化的结果内容;该行绝不关联当前 skill 目录来读取描述或提供方元数据。现有的 ACP(Agent Client Protocol)`skill-load` 记录经由真实的 Web 持久化与组合路径写入,用于无需密钥的交互和无障碍快照。 +该行的所有可见值均派生自已记录的调用/结果片段。skill 名称来自已记录的 `name` 参数,指令来自持久化的结果内容;该行绝不关联当前 skill 目录来读取描述或提供方元数据。由于 history 页可能包含 `tool/result`,而与之配对的 `tool/call` 已落在窗口外,通用 `HistoryEntry` envelope 现在会在结果条目上携带配对调用的名称、精确的 arguments JSON 和事件时间。Host 从完整日志派生这份瞬时注解和结果渲染意图;runtime 优先使用窗口内调用,否则从该注解物化出相同的 `ToolResultNode.call` 和 `callTime`。无配对结果仍为 `call: null`;调用事件位于页面外时,调用侧渲染意图仍不可用。现有的 ACP(Agent Client Protocol)`skill-load` 记录经由真实的 Web 持久化与组合路径写入,用于无需密钥的交互和无障碍快照。 ## 考虑过的替代方案 - 保留通用工具行,只添加一个 `skill` 颜色选择器,并将其放在 `ui-conversation` 中。该方案仍会保留多余的输入外层结构和通用展开体,也会让 conversation 包拥有特定领域的视觉规则。 -- 在宿主工具渲染意图联合类型中添加新的 `skill` 值。键控客户端 slot 无需更改协议契约即可识别该工具,因此新的跨边界呈现值只会增加协议与快照表层,却没有为其他消费方提供新能力。 +- 在宿主工具渲染意图联合类型中添加新的 `skill` 值。键控客户端 slot 已经能够识别该工具;跨页修复属于所有工具共用的通用 history 配对 envelope,而不是 skill 专用的呈现值。 - 导出 conversation 包的私有 `ToolRow` 组件供复用。客户端包刻意对外暴露契约而非跨包组件;导出该组件会使独立功能包耦合到 conversation 的实现细节。 ## 后果 除了引用 source 的依赖外,`ui-skill` 现在还依赖公开的 conversation toolview 契约、locale 包、原语包和 React。它自行保留了一小份折叠展开行 chrome,因此未来的全局交互变更必须与 Bash 示例和 conversation 行同步更新这个注册方。 -即使已安装的 skill 目录发生变化,冷回放仍具有确定性;在用户显式展开指令前,transcript 保持紧凑。专用卡片有意显示工具完整封装的输出,而不是只提取 `<skill_instructions>`,从而原样保留模型实际收到的内容,也避免为 skill 结果格式再引入一个解析器。 +无论跨越分页,还是已安装的 skill 目录发生变化,冷回放都保持确定性;在用户显式展开指令前,transcript 保持紧凑。通用配对注解还可防止其他键控工具行和结果 presenter 在分页边界改变身份,同时无需持久化重复数据。专用卡片有意显示工具完整封装的输出,而不是只提取 `<skill_instructions>`,从而原样保留模型实际收到的内容,也避免为 skill 结果格式再引入一个解析器。 diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index 6f29b2dda0..de15a9c67f 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -7,7 +7,7 @@ export type { ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, - ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, + ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, HistoryToolCall, ToolEventView, DirectoryEntry, DirectoryListing, WorkspaceApi, WorkspaceId, WorkspaceView, CommandsApi, CommandDescriptor, SkillsApi, SkillEntry, diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 20af221f2d..5a7367acd0 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -29,7 +29,7 @@ import type { import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import { deriveEventMessage, foldSurface } from '@deepseek-ai/dsh-session/surface' import type { - ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt, + ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HistoryToolCall, HostFrame, MuxFrame, RpcReceipt, ModelProviderGroup, ModelTarget, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary, ToolCallView, ToolEventView, ToolResultView, WorkspaceId, WorkspaceView, } from './api.ts' @@ -661,25 +661,33 @@ function presentResult(name: string, argsRaw: string, resultText: string): ToolR } } -/** Host-side viewFor mirror: tool/call presents from its own args; tool/result back-scans the log for the paired call. */ +/** Full-log tool/result pair used by the fixture history envelope and presenter mirror. */ +function pairedHistoryCall(event: SessionEvent, log: readonly SessionEvent[]): HistoryToolCall | undefined { + if (event.type !== 'tool/result') return undefined + const callId = String(event.data.message.source.callId) + for (let i = log.length - 1; i >= 0; i--) { + const candidate = log[i] + /* v8 ignore next -- dense-array guard: i stays within [0, log.length), + so the undefined arm needs a sparse log no code path builds. */ + if (candidate !== undefined && candidate.type === 'tool/call' && String(candidate.data.callId) === callId) { + return { name: candidate.data.name, arguments: candidate.data.arguments, time: candidate.time } + } + } + return undefined +} + +/** Host-side viewFor mirror: tool/call presents from its own args; tool/result uses its full-log pair. */ function viewFor(event: SessionEvent, log: readonly SessionEvent[]): ToolEventView | undefined { if (event.type === 'tool/call') { const view = presentCall(event.data.name, event.data.arguments) return view === undefined ? undefined : { for: 'call', view } } if (event.type === 'tool/result') { - const callId = String(event.data.message.source.callId) - for (let i = log.length - 1; i >= 0; i--) { - const candidate = log[i] - /* v8 ignore next -- dense-array guard: i stays within [0, log.length), - so the undefined arm needs a sparse log no code path builds. */ - if (candidate !== undefined && candidate.type === 'tool/call' && String(candidate.data.callId) === callId) { - const resultText = event.data.message.content[0].content.map(b => (b.type === 'text' ? b.text : '')).join('') - const view = presentResult(candidate.data.name, candidate.data.arguments, resultText) - return view === undefined ? undefined : { for: 'result', view } - } - } - return undefined // cross-page unpaired: documented default + const call = pairedHistoryCall(event, log) + if (call === undefined) return undefined + const resultText = event.data.message.content[0].content.map(b => (b.type === 'text' ? b.text : '')).join('') + const view = presentResult(call.name, call.arguments, resultText) + return view === undefined ? undefined : { for: 'result', view } } return undefined } @@ -1044,7 +1052,12 @@ function pageOf( } const events = log.slice(start, end).map((event): HistoryEntry => { const view = viewFor(event, log) - return view === undefined ? { event } : { event, view } + const call = pairedHistoryCall(event, log) + return { + event, + ...view === undefined ? {} : { view }, + ...call === undefined ? {} : { call }, + } }) return { events, hasMore: start > 0 } } diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 67b47b06c6..83e9722a49 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -13,7 +13,7 @@ import { isLoopbackHostname } from '../loopback-hostname.ts' // ---- Contract re-exports (browser-safe apiproxy channels + core types) ---- export type { ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, - ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, + ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, HistoryToolCall, ToolEventView, DirectoryEntry, DirectoryListing, ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView, CommandsApi, CommandDescriptor, SkillsApi, SkillEntry, diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 23c867e4c0..ef94a8834c 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/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/client/runtime/README.md -README.md: 8ac29a4258bbd7456b20c61e547d48c570e84d27 -README.zh.md: 0e065e43ecc571e68d3976d2100eb43959cb2e3d +README.md: 3d981392ce0314f41fe84bc1adb2b9484a6a5989 +README.zh.md: c05bdb6ebb33c0ffa47e2b54fb1b3d9d25f2fa6d diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 8ac29a4258..3d981392ce 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -34,7 +34,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and ## The human transcript -`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. `SteeringHistory` replays the durable `agent/inbox/spliced` records in that window: a user-origin message claimed from `next-step` becomes a `SteeringMessageNode` when its matching `user/message` lands, a `next-turn` claim stays a user node, and non-user next-step input stays context. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before enabling an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. Each context node also carries a `provenance` view: `contextProvenance()` reads the durable source alone to decide whether the row is an `inject` or a cross-session `recall`, and to name its producer from the instruction paths, referenced session titles, or plugin id that source already records. The client holds no table of plugin ids, so a renamed or newly mounted producer stays identifiable without a client release and a resumed or foreign log projects exactly like a live one; a source with no readable kind degrades to an unnamed injection. Beside it, `contextForm()` reads the producer-declared `ContextForm` — the second, independent axis: `kind` says who produced the context, `form` says what shape of information it is, so several producers may share one form. A form this UI version does not present projects as null and renders opaque. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's). +`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. A paged `tool/result` first pairs against an in-window `tool/call`, then against the Host-carried complete-log call annotation; `ToolResultNode.call` is null only for a truly orphaned durable result, so a page boundary cannot change keyed toolview dispatch, argument-derived labels, or duration. The call-side render intent remains null when its event is outside the window, while the result intent is already computed by the Host from the complete pair. `SteeringHistory` replays the durable `agent/inbox/spliced` records in that window: a user-origin message claimed from `next-step` becomes a `SteeringMessageNode` when its matching `user/message` lands, a `next-turn` claim stays a user node, and non-user next-step input stays context. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before enabling an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. Each context node also carries a `provenance` view: `contextProvenance()` reads the durable source alone to decide whether the row is an `inject` or a cross-session `recall`, and to name its producer from the instruction paths, referenced session titles, or plugin id that source already records. The client holds no table of plugin ids, so a renamed or newly mounted producer stays identifiable without a client release and a resumed or foreign log projects exactly like a live one; a source with no readable kind degrades to an unnamed injection. Beside it, `contextForm()` reads the producer-declared `ContextForm` — the second, independent axis: `kind` says who produced the context, `form` says what shape of information it is, so several producers may share one form. A form this UI version does not present projects as null and renders opaque. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's). Because the projection is log-ordered, the node array is seq-monotonic by construction: log-only `command/run` / `command/done` nodes splice in by seq, `Session` merges interrupted frozen nodes by their fractional seqs, and a window whose checkpoint cites a shadowed range outside it renders the marker with nothing logged. The marker's summary text comes from the checkpoint's `compact/summary` provenance; a window cut that left the provenance outside makes the row non-expandable rather than empty, and a later page that supplies it resolves the text. Performance contract: one append materializes at most one node and copies the projection only when it adds that node; an event that changes no node keeps the previous array reference (a chunk storm costs nothing), and unchanged nodes keep their object identity. diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 0e065e43ec..c05bdb6ebb 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -34,7 +34,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 ## 面向人的 transcript(文本记录) -`ConversationSnapshot.nodes` 是面向人的 transcript,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口。每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,每次落地的压缩(compaction)检查点还会贡献一个 `CompactionSummaryNode` 标记;适配器从不查询 surface 顺序。`SteeringHistory` 会重放该窗口中的持久 `agent/inbox/spliced` 记录:用户来源的消息从 `next-step` 被领取,并以相同身份落成 `user/message` 时,会投影为 `SteeringMessageNode`;从 `next-turn` 领取的消息仍是用户节点,非用户来源的 next-step 输入仍是上下文。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq;它独立于 transcript 保留轮次完成状态,使呈现层能够在启用操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。每个上下文节点还携带一份 `provenance` 视图:`contextProvenance()` 只读取持久来源,据此判定该行是 `inject`(注入)还是跨会话的 `recall`(召回),并用该来源已经记录的指令文件路径、被引用会话标题或插件 id 命名其生产者。客户端不保存任何插件 id 表,因此重命名或新挂载的生产者无需客户端发版即可保持可辨识,恢复的会话日志与外部日志的投影结果和实时会话完全一致;没有可读 kind 的来源则降级为无名注入。与之并列的 `contextForm()` 读取生产方声明的 `ContextForm`,这是相互独立的第二根轴:`kind` 说明上下文由谁产生,`form` 说明它是何种形态的信息,因此多个生产方可以共用一种形态。本 UI 版本不呈现的形态投影为 null,按 opaque 渲染。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败;而对该包(package)做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。 +`ConversationSnapshot.nodes` 是面向人的 transcript,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口。每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,每次落地的压缩(compaction)检查点还会贡献一个 `CompactionSummaryNode` 标记;适配器从不查询 surface 顺序。分页得到的 `tool/result` 会先与窗口内的 `tool/call` 配对,再与 Host 携带的完整日志调用注解配对;只有持久结果确实没有配对调用时,`ToolResultNode.call` 才为 null,因此分页边界无法改变键控 toolview 分派、由参数派生的标签或耗时。调用事件位于窗口外时,调用侧渲染意图仍为 null,而结果侧渲染意图已经由 Host 基于完整配对计算完成。`SteeringHistory` 会重放该窗口中的持久 `agent/inbox/spliced` 记录:用户来源的消息从 `next-step` 被领取,并以相同身份落成 `user/message` 时,会投影为 `SteeringMessageNode`;从 `next-turn` 领取的消息仍是用户节点,非用户来源的 next-step 输入仍是上下文。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq;它独立于 transcript 保留轮次完成状态,使呈现层能够在启用操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。每个上下文节点还携带一份 `provenance` 视图:`contextProvenance()` 只读取持久来源,据此判定该行是 `inject`(注入)还是跨会话的 `recall`(召回),并用该来源已经记录的指令文件路径、被引用会话标题或插件 id 命名其生产者。客户端不保存任何插件 id 表,因此重命名或新挂载的生产者无需客户端发版即可保持可辨识,恢复的会话日志与外部日志的投影结果和实时会话完全一致;没有可读 kind 的来源则降级为无名注入。与之并列的 `contextForm()` 读取生产方声明的 `ContextForm`,这是相互独立的第二根轴:`kind` 说明上下文由谁产生,`form` 说明它是何种形态的信息,因此多个生产方可以共用一种形态。本 UI 版本不呈现的形态投影为 null,按 opaque 渲染。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败;而对该包(package)做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。 由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时该行不可展开而非空白,后续补上溯源的分页会解析出文本。性能契约:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。 diff --git a/packages/client/runtime/src/client/session-history/history-fold.ts b/packages/client/runtime/src/client/session-history/history-fold.ts index d792fd2b76..83a09d3163 100644 --- a/packages/client/runtime/src/client/session-history/history-fold.ts +++ b/packages/client/runtime/src/client/session-history/history-fold.ts @@ -362,7 +362,8 @@ export function projectConversationHistory( let contextGeneration = 0 for (const [index, event] of events.entries()) { - const view = entries[index]?.view + const entry = entries[index] + const view = entry?.view if (event.type === 'tool/call') { callIndex.set(String(event.data.callId), { name: event.data.name, @@ -370,8 +371,17 @@ export function projectConversationHistory( time: event.time, callView: view?.for === 'call' ? view.view : null, }) - } else if (event.type === 'tool/result' && view?.for === 'result') { - resultViews.set(event.seq, view.view) + } else if (event.type === 'tool/result') { + const callId = String(event.data.message.source.callId) + if (!callIndex.has(callId) && entry?.call !== undefined) { + callIndex.set(callId, { + name: entry.call.name, + argsRaw: entry.call.arguments, + time: entry.call.time, + callView: null, + }) + } + if (view?.for === 'result') resultViews.set(event.seq, view.view) } if (isSurfaceEvent(event) && event.surfaceOp !== 'append') { contextGeneration++ diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index d24b963d6b..14bd0dc9ed 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -155,16 +155,16 @@ export interface TurnErrorNode { code?: string } -/** A tool result paired (when in-window) with its call head. */ +/** A tool result paired with its durable call head when the Host can resolve it. */ export interface ToolResultNode { kind: 'tool-result' seq: number /** Unix epoch ms from the tool/result session event. */ time: number callId: string - /** Call head backfilled from the in-window tool/call; null when window truncation left the call outside (card head shows callId). */ + /** Call head from the window or history envelope; null only when the durable log has no pair (card head shows callId). */ call: { name: string; argsRaw: string } | null - /** Unix epoch ms of the paired tool/call when the call is still in-window; used for call-row duration. */ + /** Unix epoch ms of the paired tool/call; null when the durable log has no pair. */ callTime: number | null content: readonly ContentBlock[] isError: boolean diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 776f4494fd..e663af8bf6 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -5,7 +5,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { - HistoryEntry, IApiClient, MessageId, MuxFrame, QueueAction, RpcError, + HistoryEntry, HistoryToolCall, IApiClient, MessageId, MuxFrame, QueueAction, RpcError, RpcId, RpcResponse, RpcResult, SessionId, SubagentAddress, ToolEventView, } from '@deepseek-ai/dsh-client-connection/client' // Value import from the inline-safe wire layer (not the connection plugin): @@ -85,6 +85,8 @@ export class Session implements SessionFace { /** Wire views aligned with `events` by index (envelope-level annotations; undefined = no view). * Kept parallel rather than merged so `events` stays the raw log slice (model-visible ⟺ logged). */ private views: (ToolEventView | undefined)[] = [] + /** Host-carried call metadata aligned with result entries when the call event is outside the page. */ + private historyCalls: (HistoryToolCall | undefined)[] = [] private baseSeq = 0 private hasMore = false private openState: OpenState = 'cold' @@ -381,10 +383,11 @@ export class Session implements SessionFace { } this.events = [...older.map(e => e.event), ...this.events] this.views = [...older.map(e => e.view), ...this.views] + this.historyCalls = [...older.map(e => e.call), ...this.historyCalls] /* v8 ignore next -- the ?? arm needs older[0] undefined, but the empty-page branch above already returned. */ this.baseSeq = older[0]?.event.seq ?? this.baseSeq this.hasMore = result.value.hasMore - this.transcript.reset(this.events, this.views) // prepend forces a rebuild (the window grew at the head) + this.transcript.reset(this.events, this.views, this.historyCalls) // prepend forces a rebuild (the window grew at the head) this.rebuildDerivedFromWindow() } catch (error) { console.error('[web-runtime] loadOlder failed:', error) @@ -411,6 +414,7 @@ export class Session implements SessionFace { this.openError = null this.events = [] this.views = [] + this.historyCalls = [] this.baseSeq = 0 // Superseded, not settled: the baseline replay re-sends still-pending requested frames verbatim // (same rpcId), re-minting fresh waits; a stale reference's respond() still reaches the host. @@ -644,9 +648,10 @@ export class Session implements SessionFace { private installWindow(entries: HistoryEntry[], hasMore: boolean, projections?: ProjectionsBaseline): void { this.events = entries.map(e => e.event) this.views = entries.map(e => e.view) + this.historyCalls = entries.map(e => e.call) this.baseSeq = this.events[0]?.seq ?? 0 this.hasMore = hasMore - this.transcript.reset(this.events, this.views) + this.transcript.reset(this.events, this.views, this.historyCalls) this.rebuildDerivedFromWindow() if (projections !== undefined) this.projections.seed(projections) const buffered = this.liveBuffer @@ -661,6 +666,7 @@ export class Session implements SessionFace { if (tailSeq !== null && event.seq <= tailSeq) return // replay overlap, drop this.events.push(event) this.views.push(view) + this.historyCalls.push(undefined) this.transcript.append(event, view) this.handoffPendingSteering(event) this.applyEventSideEffects(event, view) diff --git a/packages/client/runtime/src/client/sessions/transcript-adapter.ts b/packages/client/runtime/src/client/sessions/transcript-adapter.ts index 306571b2bf..b1d952b804 100644 --- a/packages/client/runtime/src/client/sessions/transcript-adapter.ts +++ b/packages/client/runtime/src/client/sessions/transcript-adapter.ts @@ -19,7 +19,9 @@ import type { CommandId } from '@deepseek-ai/dsh-commands/brand' // `sessions: ISessions` (TS2717, the one-program-per-side rule in // docs/development.md). import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpoint' -import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' +import type { + HistoryToolCall, ToolCallView, ToolEventView, ToolResultView, +} from '@deepseek-ai/dsh-client-connection/client' import type { CommandNode, CompactionSummaryNode, ConversationNode } from './conversation.ts' import { toAssistantBlocks } from './conversation.ts' import { contextForm, contextProvenance } from './context-provenance.ts' @@ -213,8 +215,13 @@ export class TranscriptAdapter { * and re-project the transcript. * @param events - the new window contents (seq-ascending). * @param views - per-event wire views aligned with `events` by index (undefined slots for view-less events). + * @param calls - host-carried result pairs aligned with `events` by index. */ - reset(events: readonly SessionEvent[], views?: readonly (ToolEventView | undefined)[]): void { + reset( + events: readonly SessionEvent[], + views?: readonly (ToolEventView | undefined)[], + calls?: readonly (HistoryToolCall | undefined)[], + ): void { this.rev++ this.eventIndex = new Map() this.callIdx = new Map() @@ -228,7 +235,7 @@ export class TranscriptAdapter { /* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */ if (event === undefined) continue this.eventIndex.set(event.seq, event) - this.indexCall(event, views?.[i]) + this.indexCall(event, views?.[i], calls?.[i]) this.indexCommand(event) if (this.steeringHistory.apply(event)) steeringSeqs.add(event.seq) indexAssistantStepTiming(this.stepTimings, event) @@ -338,9 +345,20 @@ export class TranscriptAdapter { return true } - private indexCall(event: SessionEvent, view?: ToolEventView): void { + private indexCall(event: SessionEvent, view?: ToolEventView, pairedCall?: HistoryToolCall): void { if (event.type === 'tool/result') { if (view?.for === 'result') this.resultViews.set(event.seq, view.view) + const callId = String(event.data.message.source.callId) + if (!this.callIdx.has(callId) && pairedCall !== undefined) { + this.callIdx.set(callId, { + name: pairedCall.name, + argsRaw: pairedCall.arguments, + turn: event.data.turn, + step: event.data.step, + time: pairedCall.time, + callView: null, + }) + } return } if (event.type !== 'tool/call') return diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index e50574d102..b13e27f3c4 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -3,7 +3,7 @@ // deferred-controlled timing). Streams are hand pumps: pushMux/pushHost. import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import type { - ClientResponse, CommandDescriptor, HostFrame, IApiClient, ModelTarget, MuxFrame, + ClientResponse, CommandDescriptor, HistoryEntry, HostFrame, IApiClient, ModelTarget, MuxFrame, RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-client-connection/client' @@ -68,7 +68,7 @@ export class FakeApiClient implements IApiClient { onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 })) onFork: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId })) onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) - => Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> = + => Promise<RpcResponse<{ events: HistoryEntry[]; hasMore: boolean }>> = () => Promise.resolve(ok({ events: [], hasMore: false })) onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({ diff --git a/packages/client/runtime/tests/history-fold.spec.ts b/packages/client/runtime/tests/history-fold.spec.ts index 083bdc3566..f9b40bdb7c 100644 --- a/packages/client/runtime/tests/history-fold.spec.ts +++ b/packages/client/runtime/tests/history-fold.spec.ts @@ -53,6 +53,20 @@ describe('projectConversationHistory', () => { }]) }) + it('projects a paged tool result from its host-carried call pair', () => { + const result = ev.toolResult(50, 3, 'outside-call', '已加载 skill') + const projection = projectConversationHistory([{ + event: result, + call: { name: 'skill', arguments: '{"name":"dsh-code-review"}', time: 40 }, + }]) + expect(projection.eventNodes).toMatchObject([{ + kind: 'tool-result', + call: { name: 'skill', argsRaw: '{"name":"dsh-code-review"}' }, + callTime: 40, + callView: null, + }]) + }) + it('projects a high-sequence history window without synthesizing its unloaded prefix', () => { const baseSeq = 400_000 const events = [ diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index c288c044ee..02753fe09e 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -53,6 +53,23 @@ describe('open', () => { expect(snapshot.turnEnds.get(3)).toBe(15) }) + it('installs host-carried call metadata for a result-only tail page', async () => { + const { api, session } = makeSession() + api.onHistory = () => Promise.resolve(ok({ + events: [{ + event: ev.toolResult(50, 3, 'outside-call', '已加载 skill'), + call: { name: 'skill', arguments: '{"name":"dsh-code-review"}', time: 40 }, + }], + hasMore: true, + })) + await session.open() + expect(session.getSnapshot().nodes).toMatchObject([{ + kind: 'tool-result', + call: { name: 'skill', argsRaw: '{"name":"dsh-code-review"}' }, + callTime: 40, + }]) + }) + it('is idempotent: concurrent opens share one history call, reopening when open is a no-op', async () => { const { api, session } = makeSession() await Promise.all([session.open(), session.open()]) diff --git a/packages/client/runtime/tests/transcript-adapter.spec.ts b/packages/client/runtime/tests/transcript-adapter.spec.ts index 031acf1780..99b4cdf261 100644 --- a/packages/client/runtime/tests/transcript-adapter.spec.ts +++ b/packages/client/runtime/tests/transcript-adapter.spec.ts @@ -365,6 +365,22 @@ describe('TranscriptAdapter', () => { expect(adapter.nodes()[0]).toMatchObject({ kind: 'tool-result', callId: 'outside-call', call: null }) }) + it('materializes a paged tool-result from its host-carried call pair', () => { + const adapter = new TranscriptAdapter() + adapter.reset( + [ev.toolResult(50, 3, 'outside-call', '已加载 skill')], + [undefined], + [{ name: 'skill', arguments: '{"name":"dsh-code-review"}', time: 40 }], + ) + expect(adapter.nodes()[0]).toMatchObject({ + kind: 'tool-result', + callId: 'outside-call', + call: { name: 'skill', argsRaw: '{"name":"dsh-code-review"}' }, + callTime: 40, + callView: null, + }) + }) + it('materializes a tool-result error field when present', () => { const adapter = new TranscriptAdapter() adapter.reset([ diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx index 71cf69cc5f..ac48604fc9 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx @@ -23,7 +23,7 @@ import { useEffect, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' import clsx from 'clsx' import { - CodeBlock, DiffBlock, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock, + CodeBlock, DiffBlock, IconInspectOutline12, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock, } from '@deepseek-ai/dsh-client-ui-primitives' import type { WebBlockProps } from '@deepseek-ai/dsh-client-ui-primitives' import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' @@ -99,15 +99,6 @@ export interface ToolRowProps { inspect?: (() => void) | undefined } -/** The Inspect pill's code glyph (user-supplied 16×16), fill follows text color. */ -function IconInspect() { - return ( - <svg width="12" height="12" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden> - <path d="M16 8L10.8571 12V10.552L14.1383 8L10.8571 5.448V4L16 8ZM5.14286 10.552L1.86171 8L5.14286 5.448V4L0 8L5.14286 12V10.552ZM9.02514 4L5.59657 12H6.84057L10.2691 4H9.02514Z" fill="currentColor" /> - </svg> - ) -} - /** Leading-slot state substitution: the tool icon yields to the terminal state * semantic (error = red, interrupted = amber halo). Running keeps the icon — * the row sweep (CSS on data-state) carries the in-flight signal. */ @@ -319,7 +310,7 @@ export function ToolRow({ className={css.inspectButton} onClick={inspect} > - <IconInspect /> + <IconInspectOutline12 /> Inspect </button> )} diff --git a/packages/client/ui-conversation/src/client/contract/terminal-card-model.ts b/packages/client/ui-conversation/src/client/contract/terminal-card-model.ts index 8a0c887990..b1c4cbe757 100644 --- a/packages/client/ui-conversation/src/client/contract/terminal-card-model.ts +++ b/packages/client/ui-conversation/src/client/contract/terminal-card-model.ts @@ -168,11 +168,12 @@ function collapse(body: string, rooted: boolean, separator = '/'): string { * returns a generic fenced card for an execution error or a background * start, whose text and error styling the generic path preserves. * - * Window truncation can drop the call head from a settled result (see - * `ToolResultNode.call`/`callView` in dsh-client-runtime), leaving a terminal - * result with no call side. That still renders: the command falls back to the - * result view's replacement title, then to an empty command (the prompt line - * draws bare), and the prompt shows no cwd. + * Window truncation can drop the call event and its call-side view from a + * settled result (see `ToolResultNode.callView` in dsh-client-runtime), leaving + * a terminal result with no presentation call side even though the history + * envelope preserves its name and arguments. That still renders: the command + * falls back to the result view's replacement title, then to an empty command + * (the prompt line draws bare), and the prompt shows no cwd. * @param block - RunningToolCall or ToolResultNode off the snapshot caches. * @param sessionCwd - the session workspace root, which resolves an omitted or * relative view cwd (see {@link resolveTerminalCwd}); absent leaves both unresolved. diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx index 54e021639f..adf4ac4355 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx @@ -17,7 +17,7 @@ import { useState, type KeyboardEvent } from 'react' import type { Context } from 'cordis' import clsx from 'clsx' import { - IconApiOutline14, IconChevronDownOutline14, StateDot, TerminalBlock, + IconApiOutline14, IconChevronDownOutline14, IconInspectOutline12, StateDot, TerminalBlock, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolRowProps } from '../contract/slots.ts' @@ -153,9 +153,7 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: )} {inspect !== undefined && ( <button type="button" className={css.inspectButton} onClick={inspect}> - <svg width="12" height="12" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden> - <path d="M16 8L10.8571 12V10.552L14.1383 8L10.8571 5.448V4L16 8ZM5.14286 10.552L1.86171 8L5.14286 5.448V4L0 8L5.14286 12V10.552ZM9.02514 4L5.59657 12H6.84057L10.2691 4H9.02514Z" fill="currentColor" /> - </svg> + <IconInspectOutline12 /> Inspect </button> )} diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index b7ca8dd149..e83c82130d 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -282,12 +282,12 @@ describe('chat-flow derivation', () => { }) describe('ChatView', () => { - it('a windowless tool result (call head truncated) renders with an empty tool name', () => { + it('an orphan tool result renders through the generic fallback', () => { const h = makeHarness({ nodes: [{ ...toolResult(3, 'w1'), call: null }], }) const view = render(<h.ChatView {...h.props} />) - // classifyTool('') → others; the summary slot falls back to the callId. + // No durable call exists for this id, so the summary falls back to callId. expect(view.container.querySelector('[data-variant="others"]')).not.toBeNull() expect(view.getByText('w1')).toBeTruthy() }) diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index b0b76e164b..5b99a0e71c 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -750,6 +750,13 @@ export const IconSparkle16 = ({ size = 16, className }: IconProps) => ( </svg> ) +/** inspect_outline_12 (shared tool-row trajectory affordance glyph) */ +export const IconInspectOutline12 = ({ size = 12, className }: IconProps) => ( + <svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden> + <path d="M16 8L10.8571 12V10.552L14.1383 8L10.8571 5.448V4L16 8ZM5.14286 10.552L1.86171 8L5.14286 5.448V4L0 8L5.14286 12V10.552ZM9.02514 4L5.59657 12H6.84057L10.2691 4H9.02514Z" fill="currentColor" /> + </svg> +) + /** skill_outline_16 (skill tool-row glyph; document instructions + sparkle) */ export const IconSkillOutline16 = ({ size = 16, className }: IconProps) => ( <svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> diff --git a/packages/client/ui-primitives/tests/icons.spec.tsx b/packages/client/ui-primitives/tests/icons.spec.tsx index 92f0d3cc37..678eb9754e 100644 --- a/packages/client/ui-primitives/tests/icons.spec.tsx +++ b/packages/client/ui-primitives/tests/icons.spec.tsx @@ -16,8 +16,8 @@ const icons = Object.fromEntries( const iconNames = Object.keys(icons) describe('ic_ds_ icon set', () => { - it('exports the full P-I set (46 deepsuite + 17 figma extracts + two hand-authored product glyphs)', () => { - expect(iconNames.length).toBe(65) + it('exports the full P-I set (46 deepsuite + 17 figma extracts + three product glyphs outside those sets)', () => { + expect(iconNames.length).toBe(66) }) it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => { diff --git a/packages/client/ui-skill/README.i18n.yaml b/packages/client/ui-skill/README.i18n.yaml index d23f68ee85..5c50d22b89 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: 2280c9302dbc46cff723752f88c47940f98417d5 -README.zh.md: 0e9344ff63139f77461b02b48e18b0e94e54c223 +README.md: ba9f1faae0f70a0f7bed4641e02703cc26bcb692 +README.zh.md: f8210a885d201cbdc89d7a34704a819e80463d2c diff --git a/packages/client/ui-skill/README.md b/packages/client/ui-skill/README.md index 2280c9302d..ba9f1faae0 100644 --- a/packages/client/ui-skill/README.md +++ b/packages/client/ui-skill/README.md @@ -10,7 +10,7 @@ The `/client` export surface is the plugin body (`apply`/`inject`) only; the sou ## Skill tool row -The browser plugin also registers a keyed `skill` toolview in `conversation.chat.toolview`. A collapsed row renders the 16-pixel skill document-and-sparkle glyph, `Skill` title, separator, and requested skill name with the same neutral hierarchy as the Bash row; running calls carry the transcript shimmer, failures replace the name with the first error line, and interrupted calls use the warning state. A settled row expands as one whole-row disclosure into a bounded `Instructions` card containing the exact durable tool output, with the standard trajectory `Inspect` affordance when available. The row derives its name, lifecycle, and body only from the logged call/result slice, never from the current catalog, so cold replay remains stable even when installed skills or their descriptions change. +The browser plugin also registers a keyed `skill` toolview in `conversation.chat.toolview`. A collapsed row renders the 16-pixel skill document-and-sparkle glyph, `Skill` title, separator, and requested skill name with the same neutral hierarchy as the Bash row; running calls carry the transcript shimmer, failures replace the name with the first error line, and interrupted calls use the warning state. A settled row expands as one whole-row disclosure into a bounded `Instructions` card containing the exact durable tool output, with the standard trajectory `Inspect` affordance when available. The row derives its name, lifecycle, and body only from the logged call/result slice, using the history envelope's host-carried durable pair when pagination left the call event outside the window; it never reads the current catalog, so cold replay remains stable across page cuts and when installed skills or their descriptions change. ## Model Experience diff --git a/packages/client/ui-skill/README.zh.md b/packages/client/ui-skill/README.zh.md index 0e9344ff63..f8210a885d 100644 --- a/packages/client/ui-skill/README.zh.md +++ b/packages/client/ui-skill/README.zh.md @@ -10,7 +10,7 @@ skill(技能)引用 source 的浏览器端:把 `/` 触发的 `skill` sourc ## skill 工具行 -浏览器插件还会把一个 key 为 `skill` 的 toolview 注册进 `conversation.chat.toolview`。收起的行以与 Bash 行相同的中性色层级显示 16 像素的 skill 文档与闪光组合图标、`Skill` 标题、分隔符和请求加载的 skill 名称;运行中的调用带有 transcript(文本记录)的扫光效果,失败时用错误首行替换名称,中断调用则使用警告状态。已结算的行以整行作为展开入口,展开后显示一个尺寸受限的 `Instructions` 卡片,其中原样呈现持久化的工具输出;可用时还会提供标准执行轨迹的 `Inspect` 入口。该行的名称、生命周期和正文只派生自已记录的调用/结果片段,绝不读取当前 skill 目录,因此即使已安装的 skill 或其描述发生变化,冷回放仍保持稳定。 +浏览器插件还会把一个 key 为 `skill` 的 toolview 注册进 `conversation.chat.toolview`。收起的行以与 Bash 行相同的中性色层级显示 16 像素的 skill 文档与闪光组合图标、`Skill` 标题、分隔符和请求加载的 skill 名称;运行中的调用带有 transcript(文本记录)的扫光效果,失败时用错误首行替换名称,中断调用则使用警告状态。已结算的行以整行作为展开入口,展开后显示一个尺寸受限的 `Instructions` 卡片,其中原样呈现持久化的工具输出;可用时还会提供标准执行轨迹的 `Inspect` 入口。该行的名称、生命周期和正文只派生自已记录的调用/结果片段;分页将调用事件留在窗口外时,则使用 history envelope 中由 Host 携带的持久配对。该行绝不读取当前 skill 目录,因此冷回放在跨分页时,以及已安装的 skill 或其描述发生变化时均保持稳定。 ## 模型体验 diff --git a/packages/client/ui-skill/src/client/SkillRow.tsx b/packages/client/ui-skill/src/client/SkillRow.tsx index be1084ec39..076da55d52 100644 --- a/packages/client/ui-skill/src/client/SkillRow.tsx +++ b/packages/client/ui-skill/src/client/SkillRow.tsx @@ -4,7 +4,7 @@ import { useState, type KeyboardEvent, type ReactNode } from 'react' import { - IconChevronDownOutline14, IconSkillOutline16, StateDot, + IconChevronDownOutline14, IconInspectOutline12, IconSkillOutline16, StateDot, } from '@deepseek-ai/dsh-client-ui-primitives' import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' @@ -45,7 +45,8 @@ function skillName(argsRaw: string, callId: string): string { return argsRaw === '' ? callId : firstLine(argsRaw) } -/** Flatten the durable result exactly like the generic row's text fallback. */ +/** Flatten durable result blocks under the generic tool-row text contract. + * Keep aligned with ui-conversation's contract/tool-call-model.ts `resultText`. */ function resultText(block: ToolRowProps['block']): string | null { if (!('kind' in block)) return null const parts: string[] = [] @@ -108,15 +109,6 @@ function stateStatus(state: SkillRowState, t: SkillRowProps['t']): string | null } } -/** Inspect affordance glyph shared with the transcript's other tool rows. */ -function IconInspect() { - return ( - <svg width="12" height="12" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden> - <path d="M16 8L10.8571 12V10.552L14.1383 8L10.8571 5.448V4L16 8ZM5.14286 10.552L1.86171 8L5.14286 5.448V4L0 8L5.14286 12V10.552ZM9.02514 4L5.59657 12H6.84057L10.2691 4H9.02514Z" fill="currentColor" /> - </svg> - ) -} - /** * Render one `skill` tool call as an accent summary and instructions disclosure. * @param props - keyed toolview payload plus the skill locale seat. @@ -129,7 +121,6 @@ export function SkillRow({ block, inspect, t }: SkillRowProps) { const open = expanded && expandable const status = stateStatus(model.state, t) const summary = model.errorSummary ?? model.name - const ariaLabel = status === null ? `Skill ${summary}` : `${status} Skill ${summary}` const toggleExpand = (): void => { setExpanded(value => !value) } @@ -138,18 +129,20 @@ export function SkillRow({ block, inspect, t }: SkillRowProps) { event.preventDefault() toggleExpand() } + const disclosureProps = expandable ? { + role: 'button' as const, + tabIndex: 0, + 'aria-expanded': open, + onClick: toggleExpand, + onKeyDown: toggleFromKeyboard, + } : {} const leading = disclosureLeading(model.state, open, expandable) return ( <div className={css.card} data-tool="skill" data-state={model.state}> <div className={css.row} data-expandable={expandable || undefined} - role={expandable ? 'button' : undefined} - tabIndex={expandable ? 0 : undefined} - aria-expanded={expandable ? open : undefined} - aria-label={expandable ? ariaLabel : undefined} - onClick={expandable ? toggleExpand : undefined} - onKeyDown={expandable ? toggleFromKeyboard : undefined} + {...disclosureProps} > <span className={css.leading}>{leading}</span> {status !== null ? <span className={css.visuallyHidden}>{status}</span> : null} @@ -167,7 +160,7 @@ export function SkillRow({ block, inspect, t }: SkillRowProps) { </section> {inspect !== undefined ? ( <button type="button" className={css.inspectButton} onClick={inspect}> - <IconInspect /> + <IconInspectOutline12 /> Inspect </button> ) : null} diff --git a/packages/client/ui-skill/src/invariant.ts b/packages/client/ui-skill/src/invariant.ts index 241482a306..9246466cd1 100644 --- a/packages/client/ui-skill/src/invariant.ts +++ b/packages/client/ui-skill/src/invariant.ts @@ -15,9 +15,10 @@ export const name = 'client-ui-skill-invariant' export const inject = ['invariants'] /** - * No runtime invariant: a single slash-source registration whose disposal is - * proven by the HMR-safety spec — it emits no cordis events and owns no - * cross-plugin mutable state. + * No runtime invariant: the slash source, locale dictionaries, and keyed + * toolview are registry-owned registrations whose disposal is proven by the + * HMR-safety spec. They emit no cordis events and own no cross-plugin mutable + * state. */ const install: InvariantInstaller = () => {} diff --git a/packages/client/ui-skill/tests/browser-plugin.spec.ts b/packages/client/ui-skill/tests/browser-plugin.spec.ts index 3febb36efb..9b047a3713 100644 --- a/packages/client/ui-skill/tests/browser-plugin.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.spec.ts @@ -14,6 +14,7 @@ import { Context } from 'cordis' import { describe, expect, it, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client' import type { ClientSessionContext, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client' import { apply, inject } from '../src/client/index.ts' @@ -25,33 +26,28 @@ type ListResult = | { ok: false; error: { code: string; message: string; details: object } } type ListFn = (payload: object, signal?: AbortSignal) => Promise<{ result: ListResult }> -interface PresentationRegistration { - name: string - key?: string - locale?: string -} - interface PresentationCapture { - registration?: PresentationRegistration - component?: unknown + slots: SlotsService dictionaries: Array<{ namespace: string; dictionaries: unknown }> + localeDisposed: boolean } /** Provide the presentation registries and capture the plugin's registrations. */ function providePresentation(ctx: Context): PresentationCapture { - const capture: PresentationCapture = { dictionaries: [] } + const slots = new SlotsService(ctx) + slots.register({ + name: 'root', + children: { 'conversation.chat.toolview': { kind: 'keyed', scope: 'session' } }, + } as never, () => null) + const capture: PresentationCapture = { + slots, + dictionaries: [], + localeDisposed: false, + } ctx.provide('locale', { register(namespace: string, dictionaries: unknown) { capture.dictionaries.push({ namespace, dictionaries }) - return () => {} - }, - }) - ctx.provide('slots', { - inject(_name: string, factory: () => unknown) { factory() }, - register(registration: PresentationRegistration, component: unknown) { - capture.registration = registration - capture.component = component - return () => {} + return () => { capture.localeDisposed = true } }, }) return capture @@ -110,10 +106,10 @@ describe('apply', () => { ctx.provide('sessions', { subagentAddress: () => undefined }) const presentation = providePresentation(ctx) await ctx.plugin({ inject: [...inject], apply }).await() - expect(presentation.registration).toEqual({ - name: 'conversation.chat.toolview', key: 'skill', locale: 'skill', - }) - expect(presentation.component).toBe(SkillToolRow) + const entry = presentation.slots.entries('conversation.chat.toolview')[0] + expect(entry?.options).toMatchObject({ key: 'skill' }) + expect(entry?.locale).toBe('skill') + expect(entry?.component).toBe(SkillToolRow) expect(presentation.dictionaries).toEqual([{ namespace: 'skill', dictionaries: { zh: { @@ -138,7 +134,7 @@ describe('apply', () => { ctx.provide('sessions', {}) await ctx.plugin(SlashService).await() ctx.provide('connection', { api: { skills: { list: listOk(CATALOG) } } }) - providePresentation(ctx) + const presentation = providePresentation(ctx) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() const slash = ctx.get('slash') as SlashService @@ -153,6 +149,8 @@ describe('apply', () => { // …and fiber teardown releases it. await fiber.dispose() expect(() => slash.registerSource(rival)).not.toThrow() + expect(presentation.slots.entries('conversation.chat.toolview')).toHaveLength(0) + expect(presentation.localeDisposed).toBe(true) }) }) diff --git a/packages/client/ui-skill/tests/skill-row.spec.tsx b/packages/client/ui-skill/tests/skill-row.spec.tsx index 2dacf0a036..4143b4a7a2 100644 --- a/packages/client/ui-skill/tests/skill-row.spec.tsx +++ b/packages/client/ui-skill/tests/skill-row.spec.tsx @@ -53,7 +53,7 @@ describe('SkillRow', () => { it('renders a compact Bash-shaped summary and discloses the exact instructions', () => { const inspect = vi.fn() const view = render(<SkillRow {...props(settled(), inspect)} />) - const row = screen.getByRole('button', { name: 'Skill dsh-manage-issues' }) + const row = screen.getByRole('button', { name: 'Skilldsh-manage-issues' }) expect(row.getAttribute('aria-expanded')).toBe('false') expect(view.container.querySelector('[data-tool="skill"]')?.getAttribute('data-state')).toBe('ok') expect(view.container.querySelector('[data-tool="skill"] svg')?.getAttribute('width')).toBe('16') @@ -97,7 +97,7 @@ describe('SkillRow', () => { isError: true, error: { name: 'SkillError', code: 'missing' }, }))} />) - const row = screen.getByRole('button', { name: 'skill 加载失败 Skill SkillError: missing resource' }) + const row = screen.getByRole('button', { name: 'skill 加载失败SkillSkillError: missing resource' }) expect(view.container.querySelector('[data-tool="skill"]')?.getAttribute('data-state')).toBe('error') expect(row.textContent).not.toContain('Check SKILL.md.') fireEvent.click(row) @@ -126,7 +126,7 @@ describe('SkillRow', () => { isError: true, error: { name: 'SkillError', code: 'missing' }, }))} />) - const errorRow = screen.getByRole('button', { name: 'skill 加载失败 Skill SkillError: missing' }) + const errorRow = screen.getByRole('button', { name: 'skill 加载失败SkillSkillError: missing' }) fireEvent.click(errorRow) expect(screen.getAllByText('SkillError: missing')).toHaveLength(2) }) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 8ee0d81334..22342f8a24 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: 00b9ea075af7eb55041d48ceb3a1363e9de90397 -README.zh.md: e62e3fa99c2908afeca85b7182701f6fcdf19de9 +README.md: 7f5d7b50cf86e251b73e2e67e38939827bd7eb13 +README.zh.md: 71b40929e065981c08a2d2b4fb1e941cbef687ff diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 00b9ea075a..7f5d7b50cf 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -12,7 +12,7 @@ The layering/protocol decisions are recorded in the [GUI layering and RPC protoc Question responses are validated against their pending request before the first answer claims it. A multi-select item may carry both requested option labels in `selected` and non-empty `custom` text; a single-select item must use one or the other. Duplicate labels, unknown labels, mismatched ids, incomplete batches, and empty custom text are rejected as `bad-response`. -`session.history` reads an attached Session in memory or inspects a cold log through persistence without resuming or publishing an Agent, then pages on append-origin message boundaries. `maxMessages` counts `user/message` and `assistant/message` events that entered the surface by appending, so a model-only replacement copy consumes no quota. Each page stays one contiguous raw event range, which keeps a compaction's log-only provenance on the same page as the replacement that cites it. +`session.history` reads an attached Session in memory or inspects a cold log through persistence without resuming or publishing an Agent, then pages on append-origin message boundaries. `maxMessages` counts `user/message` and `assistant/message` events that entered the surface by appending, so a model-only replacement copy consumes no quota. Each page stays one contiguous raw event range, which keeps a compaction's log-only provenance on the same page as the replacement that cites it. A `tool/result` entry additionally carries its paired call's name, exact arguments JSON, and event time as a transient history annotation derived from the complete log, so a page cut cannot erase keyed toolview dispatch, argument-derived summaries, or duration. Result render intents use that same complete-log pair; an orphan result or malformed arguments still soft-fall to the generic presentation path. `session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index e62e3fa99c..71b40929e0 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -12,7 +12,7 @@ 首个回答认领待处理请求之前,系统会对照该请求校验问题响应。多选题的回答项可以同时携带 `selected` 中的请求选项标签与非空 `custom` 文本;单选题的回答项必须二选一。标签重复、标签未知、id 不匹配、批次不完整以及自定义文本为空都会以 `bad-response` 拒绝。 -`session.history` 按追加来源的消息边界分页:`maxMessages` 统计以追加方式进入 surface 的 `user/message` 和 `assistant/message` 事件,因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间,从而让压缩(compaction)的仅日志溯源信息与引用它的替换留在同一页。 +`session.history` 按追加来源的消息边界分页:`maxMessages` 统计以追加方式进入 surface 的 `user/message` 和 `assistant/message` 事件,因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间,从而让压缩(compaction)的仅日志溯源信息与引用它的替换留在同一页。`tool/result` 条目还会携带其配对调用的名称、精确的 arguments JSON 和事件时间,作为从完整日志派生的瞬时 history 注解,因此分页切分无法抹掉键控 toolview 分派、由参数派生的摘要或耗时。结果渲染意图使用完整日志中的同一配对;无配对结果或参数损坏时,仍会软降级到通用呈现路径。 `session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元生成一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 928ecc7b55..5b9511de93 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -26,7 +26,7 @@ import { // Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters). import type {} from '@deepseek-ai/dsh-tools' import type { - ApiProxy, CredentialView, GoalRef, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup, + ApiProxy, CredentialView, GoalRef, HistoryEntry, HistoryToolCall, HostFrame, ModelCatalogFailure, ModelProviderGroup, ModelReasoning, MuxFrame, QuestionResponsePayload, SessionProjectionsBlock, SessionSearchItem, QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, ToolEventView, WorkspaceId, WorkspaceView, @@ -408,9 +408,9 @@ function matchesQuestions(payload: QuestionResponsePayload, pending: PendingQues * Compute the render intent for a tool/call or tool/result event through the * presenters registered at this moment; every other event type gets none. A * result's presenter needs its call's parsed args — `argsFor` supplies them - * (live: the per-session call table; history: an in-page backscan), returning - * undefined when the pairing is unavailable (e.g. the call fell off the page), - * which soft-falls to no view. Presenter or JSON.parse throws also soft-fall: + * (live: the per-session call table; history: the full-log pairing index), + * returning undefined when the pairing is unavailable, which soft-falls to no + * view. Presenter or JSON.parse throws also soft-fall: * the client's documented default (generic JSON card) covers every miss. */ function viewFor(ctx: Context, event: SessionEvent, argsFor: (callId: string) => unknown): ToolEventView | undefined { @@ -442,10 +442,8 @@ function viewFor(ctx: Context, event: SessionEvent, argsFor: (callId: string) => } /** - * Resolve a tool/result's call pairing by scanning a window of events backwards - * for the matching tool/call. Used by the history path (the page is the - * window — a cross-page pairing soft-falls to no view) and by live-path table - * misses after a reconnect-eviction. + * Resolve a tool/result's call pairing by scanning a live session backwards + * for the matching tool/call after the open-call table missed. */ function backscanArgs(events: readonly SessionEvent[], callId: string): { name: string; args: unknown } | undefined { for (let i = events.length - 1; i >= 0; i--) { @@ -463,6 +461,34 @@ function backscanArgs(events: readonly SessionEvent[], callId: string): { name: return undefined } +/** Index durable call metadata once so every history result keeps its pair across page cuts. */ +function historyCallIndex(events: readonly SessionEvent[]): ReadonlyMap<string, HistoryToolCall> { + const calls = new Map<string, HistoryToolCall>() + for (const event of events) { + if (event.type !== 'tool/call') continue + calls.set(String(event.data.callId), { + name: event.data.name, + arguments: event.data.arguments, + time: event.time, + }) + } + return calls +} + +/** Parse one indexed history pair for a result presenter, soft-falling malformed arguments. */ +function historyArgs( + calls: ReadonlyMap<string, HistoryToolCall>, + callId: string, +): { name: string; args: unknown } | undefined { + const call = calls.get(callId) + if (call === undefined) return undefined + try { + return { name: call.name, args: JSON.parse(call.arguments) } + } catch { + return undefined + } +} + /** Render one detached history page through the same presenter path as ordinary history. */ function historyPage( ctx: Context, @@ -471,10 +497,18 @@ function historyPage( maxMessages: number | undefined, ): { events: HistoryEntry[]; hasMore: boolean } { const page = paginate(events, beforeSeq, maxMessages ?? DEFAULT_MAX_MESSAGES) + const calls = historyCallIndex(events) return { events: page.events.map((event) => { - const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId)) - return { event, ...view === undefined ? {} : { view } } + const view = viewFor(ctx, event, callId => historyArgs(calls, callId)) + const call = event.type === 'tool/result' + ? calls.get(String(event.data.message.source.callId)) + : undefined + return { + event, + ...view === undefined ? {} : { view }, + ...call === undefined ? {} : { call }, + } }), hasMore: page.hasMore, } diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index 4f10d92853..697e5bdeae 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -36,7 +36,7 @@ export interface ApiProxy { // ---- Domain interfaces and payload entities ---- export type { - HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, + HistoryEntry, HistoryToolCall, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, ModelReasoningEffort, ModelTarget, QueueAction, SessionModels, SessionProjectionsBlock, SessionSearchItem, SessionsApi, SessionSummary, } from './sessions.ts' diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index 9f9c4329e6..f47289e77b 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -11,7 +11,7 @@ import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' import type { - HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, + HistoryEntry, HistoryToolCall, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, ModelReasoningEffort, ModelTarget, SessionProjectionsBlock, SessionSearchItem, SessionSummary, } from './sessions.ts' import type { ToolEventView } from './events.ts' @@ -193,10 +193,18 @@ export const toolEventViewSchema = z.discriminatedUnion('for', [ z.object({ for: z.literal('result'), view: z.looseObject({ card: z.string() }) }), ]) as unknown as z.ZodType<ToolEventView> -/** One session.history item: the session event plus its optional host-computed tool view. */ +/** Paired tool/call metadata carried with a paged tool/result. */ +export const historyToolCallSchema: z.ZodType<Wire<HistoryToolCall>> = z.object({ + name: z.string(), + arguments: z.string(), + time: z.number(), +}) + +/** One session.history item: raw event plus optional host-computed tool annotations. */ export const historyEntrySchema: z.ZodType<Wire<HistoryEntry>> = z.object({ event: sessionEventSchema, view: toolEventViewSchema.optional(), + call: historyToolCallSchema.optional(), }) as unknown as z.ZodType<Wire<HistoryEntry>> /** diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 18315eef19..2a6da96db9 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -26,14 +26,26 @@ declare module '@deepseek-ai/dsh-llm' { } } +/** Paired tool/call metadata carried beside a paged result whose call may be outside the page. */ +export interface HistoryToolCall { + /** Registered tool name used for keyed presentation dispatch. */ + name: string + /** Exact durable arguments JSON from the paired tool/call. */ + arguments: string + /** Unix epoch ms of the paired tool/call event. */ + time: number +} + /** - * One history page entry: the raw event plus the optional host-computed render - * intent (same semantics as the mux frame's `view` slot — a pagination-time - * derivation, never persisted). + * One history page entry: the raw event plus optional host-computed render + * intent and result pairing. Both annotations are pagination-time derivations, + * never persisted; `call` preserves a tool/result's identity when its call + * event lies outside this page. */ export interface HistoryEntry { event: SessionEvent view?: ToolEventView + call?: HistoryToolCall } /** diff --git a/packages/host/apiproxy/tests/api-proxy-view.spec.ts b/packages/host/apiproxy/tests/api-proxy-view.spec.ts index 43083545db..3b19a26b5e 100644 --- a/packages/host/apiproxy/tests/api-proxy-view.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-view.spec.ts @@ -231,9 +231,50 @@ describe('mux live view computation', () => { ])) expect(byKey.get('tool/call:h-term')?.view).toEqual({ for: 'call', view: { card: 'terminal', title: 'ls' } }) expect(byKey.get('tool/result:h-term')?.view).toEqual({ for: 'result', view: { card: 'terminal', output: 'done' } }) + expect(byKey.get('tool/result:h-term')?.call).toEqual({ + name: 'term', arguments: '{"cmd":"ls"}', time: byKey.get('tool/call:h-term')?.event.time, + }) expect('view' in (byKey.get('tool/result:h-orphan') ?? {})).toBe(false) + expect('call' in (byKey.get('tool/result:h-orphan') ?? {})).toBe(false) expect('view' in (byKey.get('tool/result:h-bad') ?? {})).toBe(false) + expect(byKey.get('tool/result:h-bad')?.call?.arguments).toBe('{broken') expect('view' in (byKey.get('tool/result:h-plain') ?? {})).toBe(false) + expect(byKey.get('tool/result:h-plain')?.call?.name).toBe('plain') + }) + + it('carries a result pair and computes its view when the call is outside the history page', async () => { + const { ctx } = await harness() + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const session = ctx.sessions.create() + ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) + session.append('turn/start', { turn: 1 }) + const call = session.append('tool/call', { + turn: 1, step: 1, callId: CallId('cross-page'), name: 'term', arguments: '{"cmd":"tail"}', + }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + session.append('turn/start', { turn: 2 }) + appendUserText(session, 'newer message cuts the page') + const result = session.append('tool/result', { + turn: 1, step: 1, + message: createToolResultMessage({ + callId: CallId('cross-page'), + content: [{ type: 'text', text: 'late result' }], + isError: false, + }), + }, { surfaceOp: 'append' }) + + const response = await api.sessions.history({ + rpcId: RpcId('t-hist-cross-page'), + payload: { sessionId: session.id, maxMessages: 1 }, + }) + if (!response.result.ok) throw new Error('unreachable') + const entries = response.result.value.events + expect(entries.some(entry => entry.event.seq === call.seq)).toBe(false) + const entry = entries.find(candidate => candidate.event.seq === result.seq) + expect(entry).toMatchObject({ + call: { name: 'term', arguments: '{"cmd":"tail"}', time: call.time }, + view: { for: 'result', view: { card: 'terminal', output: 'done' } }, + }) }) it('counts only append-origin messages toward maxMessages and keeps compaction provenance whole', async () => { diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index b65861c1ae..3a76dd9b07 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -190,10 +190,16 @@ describe('sessions domain schemas', () => { expect(sessionHistoryRequestSchema.parse({ sessionId: 's1', beforeSeq: 3, maxMessages: 5 }).beforeSeq).toBe(3) expect(() => sessionHistoryRequestSchema.parse({ sessionId: 's1', maxMessages: 0 })).toThrow() expect(sessionHistoryValueSchema.parse({ - events: [], + events: [{ + event: { type: 'tool/result', seq: 3, time: 30, data: {} }, + call: { name: 'skill', arguments: '{"name":"review"}', time: 20 }, + }], hasMore: false, modelTarget: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - }).hasMore).toBe(false) + })).toMatchObject({ + events: [{ call: { name: 'skill', arguments: '{"name":"review"}', time: 20 } }], + hasMore: false, + }) expect(sessionModelsRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1') expect(sessionModelsValueSchema.parse({ current: { provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: 'max' }, From ab94a2f7d6463ba640af5866c4b28908d5dde3b0 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Thu, 6 Aug 2026 17:39:43 +0800 Subject: [PATCH 028/104] refactor(telemetry): centralize the default mode --- docs/config-catalog.md | 2 +- .../telemetry/session-telemetry-otel/src/index.ts | 12 ++++-------- .../session-telemetry-otel/tests/otel.spec.ts | 4 +++- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index c386ee4e6b..46766c2640 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1193,7 +1193,7 @@ export enum TelemetryMode { Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) · `OTLPExporterNodeConfigBase` (`@opentelemetry/otlp-exporter-base`) -Source: [`packages/telemetry/session-telemetry-otel/src/index.ts:83`](../packages/telemetry/session-telemetry-otel/src/index.ts) +Source: [`packages/telemetry/session-telemetry-otel/src/index.ts:79`](../packages/telemetry/session-telemetry-otel/src/index.ts) ## `@deepseek-ai/dsh-session-title` diff --git a/packages/telemetry/session-telemetry-otel/src/index.ts b/packages/telemetry/session-telemetry-otel/src/index.ts index f380d97549..36429448c6 100644 --- a/packages/telemetry/session-telemetry-otel/src/index.ts +++ b/packages/telemetry/session-telemetry-otel/src/index.ts @@ -46,12 +46,8 @@ export enum TelemetryMode { DISABLED = 'DISABLED', } -/** Supported session-sharing policies for runtime configuration validation. */ -export const TELEMETRY_MODES = [ - TelemetryMode.FULL, - TelemetryMode.FEEDBACK_ONLY, - TelemetryMode.DISABLED, -] as const +/** Default session-sharing policy for schema and direct construction. */ +export const DEFAULT_TELEMETRY_MODE = TelemetryMode.FULL const DISABLED_FEEDBACK_WARNING = 'session telemetry is DISABLED; nothing will be shared and this feedback remains local' const NON_CANONICAL_FEEDBACK_WARNING = 'session telemetry ignored a feedback event absent from the canonical session log' @@ -59,7 +55,7 @@ const DROP_RECORD: TelemetryBackend['emit'] = () => {} /** Resolve the default and reject unknown runtime values before transport setup. */ function resolveMode(mode: TelemetryMode | undefined): TelemetryMode { - const resolved = mode ?? TelemetryMode.FULL + const resolved = mode ?? DEFAULT_TELEMETRY_MODE switch (resolved) { case TelemetryMode.FULL: case TelemetryMode.FEEDBACK_ONLY: @@ -109,7 +105,7 @@ export interface Config { * axiom (and silently drop every field not re-declared). */ export const Config: z<Config> = z.object({ - mode: z.union(TELEMETRY_MODES).default(TelemetryMode.FULL), + mode: z.union(Object.values(TelemetryMode)).default(DEFAULT_TELEMETRY_MODE), exporter: z.any(), processor: z.any(), }) diff --git a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts index f7b3a007c9..f95af8db16 100644 --- a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts +++ b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts @@ -13,7 +13,7 @@ import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import { recordFeedback } from '@deepseek-ai/dsh-command-feedback' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import TelemetryOtel, { Config, TelemetryMode } from '../src/index.ts' +import TelemetryOtel, { Config, DEFAULT_TELEMETRY_MODE, TelemetryMode } from '../src/index.ts' interface Capture { headers: import('node:http').IncomingHttpHeaders @@ -330,6 +330,8 @@ describe('TelemetryOtel config fails loud', () => { expectTypeOf<Config['mode']>().toEqualTypeOf<TelemetryMode | undefined>() expectTypeOf<'FULL'>().not.toExtend<TelemetryMode>() expectTypeOf<TelemetryMode.FULL>().toExtend<TelemetryMode>() + expect(DEFAULT_TELEMETRY_MODE).toBe(TelemetryMode.FULL) + expect(Config({}).mode).toBe(DEFAULT_TELEMETRY_MODE) }) it.each([ From 6515988ec7264331dc89b5746dea7e7a7ae51059 Mon Sep 17 00:00:00 2001 From: Jiaying Ding <silver.ding@deepseek.com> Date: Thu, 6 Aug 2026 17:40:48 +0800 Subject: [PATCH 029/104] Update startup-auto-selection.e2e.ts --- apps/web/tests/startup-auto-selection.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/startup-auto-selection.e2e.ts b/apps/web/tests/startup-auto-selection.e2e.ts index f3a953c1e6..141a5b427c 100644 --- a/apps/web/tests/startup-auto-selection.e2e.ts +++ b/apps/web/tests/startup-auto-selection.e2e.ts @@ -103,7 +103,7 @@ describe('web e2e: startup auto-selection', () => { // seat with `visibility:hidden`, which Playwright reports as not visible). await page.waitForSelector(ROOT_PHASE, { timeout: 15_000 }) expect(await page.locator(ROOT_PHASE).first().getAttribute('data-phase')).toBe('hero') - expect(await page.getByText("Let's start building").isVisible()).toBe(true) + expect(await page.getByText("Into the unknown").isVisible()).toBe(true) expect(await page.locator('textarea').first().isVisible()).toBe(true) releaseHistory() From 9bb0aecb92a22a2472b76e5eb551dd4906164ee9 Mon Sep 17 00:00:00 2001 From: Jiaying Ding <silver.ding@deepseek.com> Date: Thu, 6 Aug 2026 17:41:54 +0800 Subject: [PATCH 030/104] Update hmr-live.e2e.ts --- apps/web/tests/hmr-live.e2e.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/tests/hmr-live.e2e.ts b/apps/web/tests/hmr-live.e2e.ts index df81a10402..385a516e7d 100644 --- a/apps/web/tests/hmr-live.e2e.ts +++ b/apps/web/tests/hmr-live.e2e.ts @@ -75,8 +75,8 @@ it('hot-reloads a real client-plugin source edit without refreshing the page', a if (!existsSync(binPath)) throw new Error('HMR browser test needs the built dsh bin; run pnpm run build first') const originalSource = await readFile(sourcePath) const originalBundle = await readFile(bundlePath) - const oldText = "Let's start building" - const sourceNeedle = "'hero.headline': 'Let\\'s start building'" + const oldText = "Into the unknown" + const sourceNeedle = "'hero.headline': 'Into the unknown'" const newText = `HMR UPDATED ${'x'.repeat(80)}` const updatedSource = originalSource.toString().replace(sourceNeedle, `'hero.headline': '${newText}'`) if (updatedSource === originalSource.toString()) throw new Error(`HMR source lacks ${JSON.stringify(sourceNeedle)}`) From 9a9bfbf306bbf5f0c57cabf18c33f9a2d1ce7bb0 Mon Sep 17 00:00:00 2001 From: Jiaying Ding <silver.ding@deepseek.com> Date: Thu, 6 Aug 2026 17:51:23 +0800 Subject: [PATCH 031/104] Update lifecycle-chrome.e2e.ts --- apps/web/tests/lifecycle-chrome.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index 8c81f55810..f37f02b6af 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -152,7 +152,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () } // The blank frame renders the hero, not the resident composer: the // headline plus the guidance placeholder are the empty state's anchors. - await expect.poll(() => page.getByText("Let's start building", { exact: false }).count(), { timeout: 15_000 }).toBe(1) + await expect.poll(() => page.getByText("Into the unknown", { exact: false }).count(), { timeout: 15_000 }).toBe(1) const input = page.locator('textarea').first() await input.waitFor({ timeout: 10_000 }) if (MODE !== 'record') { From c22337a71e237b7ec617fbb65c3fe6d49c76f968 Mon Sep 17 00:00:00 2001 From: Jiaying Ding <silver.ding@deepseek.com> Date: Thu, 6 Aug 2026 17:59:42 +0800 Subject: [PATCH 032/104] Update hero.expected.md --- apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md index 728dc768f8..ad060c5d59 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md @@ -20,7 +20,7 @@ - button "Settings": - img - text: Settings -- text: Let's start building Preview +- text: Into the unknown Preview - button "Choose workspace": - img - text: workspace From 342229dc14fa90a418fd47b16312db4a051afdb5 Mon Sep 17 00:00:00 2001 From: Jiaying Ding <silver.ding@deepseek.com> Date: Thu, 6 Aug 2026 18:02:23 +0800 Subject: [PATCH 033/104] Update plan-active.expected.md --- .../tests/snapshots/lifecycle-chrome/plan-active.expected.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md index 6b4d7633e5..ce2ce36af0 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md @@ -20,7 +20,7 @@ - button "Settings": - img - text: Settings -- text: Let's start building Preview +- text: Into the unknown Preview - button "Choose workspace": - img - text: workspace From 2e943a16432e4572c87783efc43d0d7272daa64d Mon Sep 17 00:00:00 2001 From: Jiaying Ding <silver.ding@deepseek.com> Date: Thu, 6 Aug 2026 18:31:44 +0800 Subject: [PATCH 034/104] Update details-session-lifecycle.e2e.ts --- apps/web/tests/details-session-lifecycle.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts index cb6c9ba914..5317c39009 100644 --- a/apps/web/tests/details-session-lifecycle.e2e.ts +++ b/apps/web/tests/details-session-lifecycle.e2e.ts @@ -121,7 +121,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false) await page.getByRole('button', { name: /^(?:New session|新.*会话)$/ }).last().click() - await page.getByText("Let's start building", { exact: false }).waitFor({ timeout: 15_000 }) + await page.getByText("Into the unknown", { exact: false }).waitFor({ timeout: 15_000 }) await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0) expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false) From 87d0fc6fc42d1fe1afa244b40e1bd4faead658f0 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 6 Aug 2026 04:31:34 -0700 Subject: [PATCH 035/104] cleanup: drop leftovers from the retired HTTP-serving revision --- packages/client/connection/src/client/fixture.ts | 1 - packages/client/connection/src/index.ts | 3 +-- .../client/connection/tests/client-apply.spec.ts | 1 - packages/client/connection/tests/node-half.spec.ts | 13 +++++-------- .../client/runtime/src/client/workspaces/service.ts | 1 - packages/client/test-runtime/src/workspaces.ts | 1 - .../src/client/chat/Deliverables.tsx | 3 +-- 7 files changed, 7 insertions(+), 16 deletions(-) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 0549fc1160..9f091b26c5 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2362,7 +2362,6 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { }) return Promise.resolve({ accepted: true }) }, - } } diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 03f8aaa257..ed4af2d21f 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -13,7 +13,7 @@ export { API_PATH } from './api-path.ts' /** Stable Cordis plugin name. */ export const name = 'client-connection' -/** Services required before mounting the routes. */ +/** Services required before mounting the route. */ export const inject = ['httpServer', 'apiProxy'] /** Plugin config: the deployment's non-loopback serving authorities. */ @@ -96,5 +96,4 @@ export function apply(ctx: Context, config?: ConnectionConfig): void { }, } ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route') - } diff --git a/packages/client/connection/tests/client-apply.spec.ts b/packages/client/connection/tests/client-apply.spec.ts index f9fe1c1b71..6892dc7721 100644 --- a/packages/client/connection/tests/client-apply.spec.ts +++ b/packages/client/connection/tests/client-apply.spec.ts @@ -62,5 +62,4 @@ describe('connection client apply', () => { } expect(seen.some(u => u.includes('/api/'))).toBe(true) }) - }) diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 216484ad67..08c65de2ba 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -30,15 +30,11 @@ function fakeRequest(headers: Record<string, string>, url = `${API_PATH}/session } /** Response recorder compatible with both the fence's short-circuit and the bridge. */ -function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown; headers?: Record<string, string> } } { - const state: { status?: number; body?: unknown; headers?: Record<string, string> } = {} +function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown } } { + const state: { status?: number; body?: unknown } = {} const response = Object.assign(new EventEmitter(), { writableEnded: false, - writeHead(value: number, headers?: Record<string, string>) { - state.status = value - if (headers !== undefined) state.headers = headers - return this - }, + writeHead(value: number) { state.status = value; return this }, write() { return true }, end(this: { writableEnded: boolean }, value?: unknown) { if (value !== undefined) state.body = value @@ -72,7 +68,8 @@ describe('connection node half', () => { it('registers the /api prefix route and removes it with the fiber', async () => { const { routes, dispose } = await mounted() - expect(routes).toMatchObject([{ kind: 'prefix', path: API_PATH }]) + expect(routes).toHaveLength(1) + expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH }) await dispose() expect(routes).toHaveLength(0) }) diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index c0eb46fcf9..a0a76670f2 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -239,7 +239,6 @@ export class WorkspacesService implements IWorkspaces { } } - /** * Rename a Workspace. * @param workspaceId - target workspace. diff --git a/packages/client/test-runtime/src/workspaces.ts b/packages/client/test-runtime/src/workspaces.ts index 95f6574405..7e626a3660 100644 --- a/packages/client/test-runtime/src/workspaces.ts +++ b/packages/client/test-runtime/src/workspaces.ts @@ -98,7 +98,6 @@ export class TestWorkspaces implements IWorkspaces { await (this.stubs.get('openPath')?.(path) as Promise<void> | undefined) } - /** * Directory picker (recorded). The default cancels (null); stub to select. * @returns the picked path, or null. diff --git a/packages/client/ui-conversation/src/client/chat/Deliverables.tsx b/packages/client/ui-conversation/src/client/chat/Deliverables.tsx index 0a0160b486..7d62e23401 100644 --- a/packages/client/ui-conversation/src/client/chat/Deliverables.tsx +++ b/packages/client/ui-conversation/src/client/chat/Deliverables.tsx @@ -2,8 +2,7 @@ // from the mutation tools' follow-along locations (see turnDeliverables), never // from the closing prose, so the answer carries its own output whether or not // the model remembered to name it. Clicking one goes through the same openFile -// the tool rows use — in the browser that is a new tab served from the session -// workspace, and outside it the Host's own opener. +// the tool rows use — the Host's own opener, on the Host machine. import type { ChatViewSlotProps } from '../contract/slots.ts' import css from './Deliverables.module.css' From f00a44fd449f221e5548c1b040d9bc9264bafa44 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 6 Aug 2026 06:21:19 -0700 Subject: [PATCH 036/104] refactor(web): move the produced-files row into its own plugin package ui-conversation now owns only the conversation.chat.turnTail hole; the row, its derivation, and its copy live in @deepseek-ai/dsh-client-ui-deliverables, composed in or out by one cordis.yml line. --- ...6-07-31-web-workspace-file-links.i18n.yaml | 4 +- .../2026-07-31-web-workspace-file-links.md | 2 +- .../2026-07-31-web-workspace-file-links.zh.md | 2 +- apps/cli/config/web.cordis.yml | 5 + apps/cli/package.json | 1 + docs/config-catalog.md | 1 + knip.json | 10 + .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../ui-conversation/src/client/apply.ts | 1 + .../src/client/chat/AssistantMarkdown.tsx | 17 +- .../src/client/chat/ChatView.tsx | 10 +- .../src/client/chat/Deliverables.tsx | 53 ----- .../src/client/chat/chat-flow.ts | 77 -------- .../src/client/contract/slots.ts | 31 ++- .../ui-conversation/src/client/index.ts | 2 +- .../ui-conversation/src/client/locales.ts | 6 - .../ui-conversation/tests/chat-view.spec.tsx | 93 +-------- .../client/ui-deliverables/README.i18n.yaml | 6 + packages/client/ui-deliverables/README.md | 21 ++ packages/client/ui-deliverables/README.zh.md | 21 ++ packages/client/ui-deliverables/package.json | 65 +++++++ .../src/client/ProducedFiles.module.css} | 0 .../src/client/ProducedFiles.tsx | 61 ++++++ .../ui-deliverables/src/client/index.ts | 42 ++++ .../ui-deliverables/src/client/locales.ts | 21 ++ .../src/client/turn-deliverables.ts | 78 ++++++++ .../ui-deliverables/src/css-modules.d.ts | 6 + packages/client/ui-deliverables/src/index.ts | 9 + .../client/ui-deliverables/src/invariant.ts | 32 +++ .../tests/produced-files.spec.tsx | 183 ++++++++++++++++++ packages/client/ui-deliverables/tsconfig.json | 30 +++ .../client/ui-deliverables/tsdown.config.ts | 3 + pnpm-lock.yaml | 34 ++++ .../verify-package-readme-model-experience.ts | 1 + tsconfig.base.json | 1 + tsconfig.client.json | 1 + 38 files changed, 685 insertions(+), 253 deletions(-) delete mode 100644 packages/client/ui-conversation/src/client/chat/Deliverables.tsx create mode 100644 packages/client/ui-deliverables/README.i18n.yaml create mode 100644 packages/client/ui-deliverables/README.md create mode 100644 packages/client/ui-deliverables/README.zh.md create mode 100644 packages/client/ui-deliverables/package.json rename packages/client/{ui-conversation/src/client/chat/Deliverables.module.css => ui-deliverables/src/client/ProducedFiles.module.css} (100%) create mode 100644 packages/client/ui-deliverables/src/client/ProducedFiles.tsx create mode 100644 packages/client/ui-deliverables/src/client/index.ts create mode 100644 packages/client/ui-deliverables/src/client/locales.ts create mode 100644 packages/client/ui-deliverables/src/client/turn-deliverables.ts create mode 100644 packages/client/ui-deliverables/src/css-modules.d.ts create mode 100644 packages/client/ui-deliverables/src/index.ts create mode 100644 packages/client/ui-deliverables/src/invariant.ts create mode 100644 packages/client/ui-deliverables/tests/produced-files.spec.tsx create mode 100644 packages/client/ui-deliverables/tsconfig.json create mode 100644 packages/client/ui-deliverables/tsdown.config.ts 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 2b75bc2eff..e38447bf37 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: da99426ecb5ca81dcc110bbd4d5c1218390ae4bd -2026-07-31-web-workspace-file-links.zh.md: 91aa94c6fe253c64125eb31fd15973a5aaff1a8f +2026-07-31-web-workspace-file-links.md: 5bac48286c0d9066154a649f418b2f9c2df36539 +2026-07-31-web-workspace-file-links.zh.md: 2b2802ad00bbf4eb84d7ad81bca3b4a0838092c9 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 da99426ecb..5bac48286c 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 @@ -14,7 +14,7 @@ Two distinct defects sat behind that. The transcript never said what a turn had ## Decision -**A finished turn ends with the files it produced.** `turnDeliverables` reads them off the mutation tools' own follow-along `locations` — a diff card, or a generic card whose `kind` is `edit` (the shape `str_replace_editor`'s insert presents) — so a turn's output is listed whether or not the closing message named it, and a new mutation tool joins by declaring what it does rather than by being added to a list. Reads, deletes, and failed calls contribute nothing; a path appears once per turn in first-seen order; accumulation resets on the turn boundary, so a turn that mutates and then ends without content text cannot spill into the next turn's row. The row renders under the closing assistant's body and above its IconActions, keyed to the seq `assistantActionsSeqs` already elects. +**A finished turn ends with the files it produced.** The row is its own plugin, `@deepseek-ai/dsh-client-ui-deliverables`, registered into the `conversation.chat.turnTail` hole the chat view renders between a closing message's body and its IconActions — ui-conversation owns the hole and the owner currency (nodes, closing seq, `openFile`), the plugin owns every policy. `producedForClosing` reads the paths off the mutation tools' own follow-along `locations` — a diff card, or a generic card whose `kind` is `edit` (the shape `str_replace_editor`'s insert presents) — so a turn's output is listed whether or not the closing message named it, and a new mutation tool joins by declaring what it does rather than by being added to a list. Reads, deletes, and failed calls contribute nothing; a path appears once per turn in first-seen order; accumulation resets on the turn boundary, so a turn that mutates and then ends without content text cannot spill into the next turn's row. One cordis.yml line composes the surface in or out; the unregistered hole renders nothing. **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. 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 91aa94c6fe..2b2802ad00 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 @@ -14,7 +14,7 @@ Status: implemented ## 决定 -**完成的一轮以它产出的文件收尾。** `turnDeliverables` 从改写工具自身的跟随文件 `locations` 中读出它们——diff 卡片,或 `kind` 为 `edit` 的 generic 卡片(即 `str_replace_editor` 的 insert 所呈现的形状)——因此无论收尾消息是否点名,这一轮的产出都会被列出;新的改写工具靠声明自己做了什么加入,而不是靠被加进某张名单。read、删除与失败的调用不贡献任何条目;同一路径在一轮内按首见顺序只出现一次;累积在 turn 边界重置,因此一轮若先改写文件、随后没有正文内容就结束,不会溢进下一轮的行里。该行渲染在收尾 assistant 正文之下、其 IconActions 之上,键控到 `assistantActionsSeqs` 早已选出的那个 seq。 +**完成的一轮以它产出的文件收尾。** 该行是独立插件 `@deepseek-ai/dsh-client-ui-deliverables`,注册进 chat 视图在收尾消息正文与其 IconActions 之间渲染的 `conversation.chat.turnTail` 空位——ui-conversation 拥有空位与 owner 通货(节点、收尾 seq、`openFile`),插件拥有全部策略。`producedForClosing` 从改写工具自身的跟随文件 `locations` 中读出路径——diff 卡片,或 `kind` 为 `edit` 的 generic 卡片(即 `str_replace_editor` 的 insert 所呈现的形状)——因此无论收尾消息是否点名,这一轮的产出都会被列出;新的改写工具靠声明自己做了什么加入,而不是靠被加进某张名单。read、删除与失败的调用不贡献任何条目;同一路径在一轮内按首见顺序只出现一次;累积在 turn 边界重置,因此一轮若先改写文件、随后没有正文内容就结束,不会溢进下一轮的行里。cordis.yml 中的一行即可把该交互面组合进来或去掉;未注册的空位什么也不渲染。 **路径链接读得出是链接。** 静止状态下就带下划线,而不只在悬停时。这是本次改动中更小的那一半,却是修复中更大的那一半。 diff --git a/apps/cli/config/web.cordis.yml b/apps/cli/config/web.cordis.yml index daf597916e..62731fd51e 100644 --- a/apps/cli/config/web.cordis.yml +++ b/apps/cli/config/web.cordis.yml @@ -141,6 +141,11 @@ - id: ui-conversation name: '@deepseek-ai/dsh-client-ui-conversation' + # Turn tail: the produced-files row under each closing assistant message. + # Remove this entry to turn the surface off; the tail hole renders empty. + - id: ui-deliverables + name: '@deepseek-ai/dsh-client-ui-deliverables' + - id: ui-workspace name: '@deepseek-ai/dsh-client-ui-workspace' diff --git a/apps/cli/package.json b/apps/cli/package.json index 4ba1da7e86..58b92bc41d 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -30,6 +30,7 @@ "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-command": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-deliverables": "workspace:^", "@deepseek-ai/dsh-client-ui-goal": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", "@deepseek-ai/dsh-client-ui-model": "workspace:^", diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 89f1529387..710022c11c 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2376,6 +2376,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts)) - `@deepseek-ai/dsh-client-ui-command` ([`packages/client/ui-command/src/index.ts`](../packages/client/ui-command/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-deliverables` ([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) - `@deepseek-ai/dsh-client-ui-goal` ([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts)) - `@deepseek-ai/dsh-client-ui-layout` ([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts)) - `@deepseek-ai/dsh-client-ui-model` ([`packages/client/ui-model/src/index.ts`](../packages/client/ui-model/src/index.ts)) diff --git a/knip.json b/knip.json index dfb8058d7c..b16e085723 100644 --- a/knip.json +++ b/knip.json @@ -127,6 +127,16 @@ "tests/**/*.tsx" ] }, + "packages/client/ui-deliverables": { + "entry": [ + "tests/**/*.spec.tsx" + ], + "project": [ + "src/**/*.ts", + "src/**/*.tsx", + "tests/**/*.tsx" + ] + }, "packages/client/web-react": { "entry": [ "tests/**/*.spec.tsx" diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index f1e7c35db7..7c0fd0e42a 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: 674cbe7ddf6f2ddc64c337cbebb4ee553e1e3f96 -README.zh.md: d3e8475c7670c57575aa8eeecbf29646c6672c2f +README.md: 8e31a41ad682dfa21d22c93673b17954a784dd4f +README.zh.md: 05b3eeb3185166a1f16596a206ef4111a50e789d diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 674cbe7ddf..8e31a41ad6 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -50,7 +50,7 @@ The chat stats line takes its token accounting from the generic token-meter `tok `src/client/` is organized by domain. `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations and composed props, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` directories import contract files and never each other. `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components and the store factory stay internal and reach the page through apply's slot registrations. -A finished turn ends with the files it produced. `chat-flow.ts`'s `turnDeliverables` reads them off the mutation tools' own follow-along `locations` — a diff card, or a generic card whose `kind` is `edit` (the shape `str_replace_editor`'s insert presents) — so a turn's output is listed whether or not the closing message named it, and a new mutation tool joins by declaring what it does rather than by being added to a list. Reads, deletes, and failed calls contribute nothing; a path appears once per turn in first-seen order; accumulation resets on the turn boundary, so a turn that mutates and then ends without content text cannot spill into the next turn's row. The row renders under the closing assistant's body and above its IconActions, keyed to the same seq `assistantActionsSeqs` elects. It shows six chips (basename, full path as the title) plus an explicit remainder count, and each chip opens through the same `openFile` the tool rows use. +A finished turn ends with a turn-tail hole: the chat view renders the `conversation.chat.turnTail` list slot between the closing assistant's body and its IconActions, once per turn at the seq `assistantActionsSeqs` elects, dispatching `TurnTailOwnerProps` (the snapshot nodes, the closing seq, and the tool rows' `openFile`). This package owns only the hole; the produced-files row that fills it — derivation from the mutation tools' `locations`, the chip cap, the copy — lives in `@deepseek-ai/dsh-client-ui-deliverables`, so composing that plugin out of cordis.yml turns the surface off while the hole renders empty at zero cost. ## Model Experience diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index d3e8475c76..05b3eeb318 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -50,7 +50,7 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu `src/client/` 按领域组织。`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明与组合后的 props、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/` 目录只导入 contract 文件,彼此之间从不互相导入。`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件与 store factory 保持内部,经 apply 的 slot 注册抵达页面。 -完成的一轮以它产出的文件收尾。`chat-flow.ts` 的 `turnDeliverables` 从改写工具自身的跟随文件 `locations` 中读出它们——diff 卡片,或 `kind` 为 `edit` 的 generic 卡片(即 `str_replace_editor` 的 insert 所呈现的形状)——因此无论收尾消息是否点名,这一轮的产出都会被列出;新的改写工具靠声明自己做了什么加入,而不是靠被加进某张名单。read、删除与失败的调用不贡献任何条目;同一路径在一轮内按首见顺序只出现一次;累积在 turn 边界重置,因此一轮若先改写文件、随后没有正文内容就结束,不会溢进下一轮的行里。该行渲染在收尾 assistant 正文之下、其 IconActions 之上,键控到 `assistantActionsSeqs` 选出的同一个 seq。它展示六枚 chip(文本为文件名,完整路径作为 title),外加一个显式的剩余计数,每枚 chip 都经由工具行所用的同一个 `openFile` 打开。 +完成的一轮以一个 turn-tail 空位收尾:chat 视图在收尾 assistant 正文与其 IconActions 之间渲染 `conversation.chat.turnTail` list slot,每轮一次、位于 `assistantActionsSeqs` 选出的 seq,派发 `TurnTailOwnerProps`(快照节点、收尾 seq,以及工具行的 `openFile`)。本包只拥有空位;填充它的产物行——从改写工具 `locations` 的派生、chip 上限、文案——都在 `@deepseek-ai/dsh-client-ui-deliverables` 里,因此把那个插件从 cordis.yml 中组合掉即可关闭该交互面,空位以零成本渲染为空。 ## 模型体验 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 6bc9068cfc..8eb78139c4 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -303,6 +303,7 @@ export function apply(ctx: Context): void { children: { 'conversation.chat.toolview': { kind: 'keyed', scope: 'session' }, 'conversation.chat.commandview': { kind: 'keyed', scope: 'session' }, + 'conversation.chat.turnTail': { kind: 'list', scope: 'session' }, }, store: chatStore, inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ChatViewInjected => { diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index 77cd3316d8..d6b6504bd0 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -9,13 +9,12 @@ // only when the node is also the completed turn's transcript tail. Think / // tool-head-only nodes stay chrome-free. -import { memo, useMemo } from 'react' +import { memo, useMemo, type ReactNode } from 'react' import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' import { IconThinkOutline14, JsonBlock, MarkdownText, } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' -import { Deliverables } from './Deliverables.tsx' import { MessageIconActions } from './MessageIconActions.tsx' import { ToolRow } from './ToolRow.tsx' import css from './AssistantMarkdown.module.css' @@ -39,11 +38,9 @@ export interface AssistantMarkdownProps { seq?: number | undefined /** Fork the session through this finalized message's completed turn when eligible. */ onFork?: ((seq: number) => void) | undefined - /** Files the closing turn produced, listed under the body; omitted for a - * mid-turn assistant and for a turn that wrote nothing. */ - produced?: readonly string[] | undefined - /** Opens one produced file; omitted wherever `produced` is. */ - openFile?: ((path: string) => void) | undefined + /** Turn-tail content (the chat view's turnTail hole, rendered by the + * owner); omitted for a mid-turn assistant. */ + tail?: ReactNode | undefined /** The message is not the transcript tail of a completed turn. */ forkUnavailable?: boolean | undefined /** The owning view's locale seat, passed down as a plain prop. */ @@ -92,7 +89,7 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass } export const AssistantMarkdown = memo(function AssistantMarkdown({ - blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, produced, openFile, t, + blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, tail, t, }: AssistantMarkdownProps) { // Stable per locale revision (t identity changes on switch): a fresh object // per render would rebuild MarkdownText's component table every chunk. @@ -130,9 +127,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ })} {interrupted && <span className={css.stopped}>{t('message.stopped')}</span>} </div> - {showActions && produced !== undefined && openFile !== undefined && ( - <Deliverables paths={produced} openFile={openFile} t={t} /> - )} + {showActions && tail} {showActions && ( <MessageIconActions text={copyText(blocks)} diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 725a7403e2..3e0015ed20 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -30,7 +30,7 @@ import type { import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' -import { assistantActionsSeqs, deriveChatFlow, messageBranchSeqs, runningTurnStartTime, turnDeliverables, type ChatFlowItem } from './chat-flow.ts' +import { assistantActionsSeqs, deriveChatFlow, messageBranchSeqs, runningTurnStartTime, type ChatFlowItem } from './chat-flow.ts' import { AssistantMarkdown } from './AssistantMarkdown.tsx' import { GenericCommandCard } from './GenericCommandCard.tsx' import { GenericToolCard } from './GenericToolCard.tsx' @@ -361,9 +361,6 @@ export function ChatView({ // Only the last content assistant of each turn owns IconActions; mid-turn // text (before tools) omits `time` so AssistantMarkdown stays chrome-free. const actionSeqs = useMemo(() => assistantActionsSeqs(nodes), [nodes]) - // Produced files per closing assistant: derived from the mutation tools' - // locations, so a turn's output is listed whether or not the model named it. - const produced = useMemo(() => turnDeliverables(nodes), [nodes]) const branchSeqs = useMemo(() => messageBranchSeqs(nodes, turnEnds), [nodes, turnEnds]) const runningTurnStart = useMemo(() => runningTurnStartTime(turnTimings), [turnTimings]) const turnMetrics = useMemo(() => deriveTurnMetrics(nodes), [nodes]) @@ -621,8 +618,9 @@ export function ChatView({ seq={node.seq} onFork={forkAt} forkUnavailable={!branchSeqs.has(node.seq)} - produced={produced.get(node.seq)} - openFile={openFile} + tail={actionSeqs.has(node.seq) + ? renderSlot('conversation.chat.turnTail', { nodes, seq: node.seq, openFile }) + : undefined} t={t} /> ) diff --git a/packages/client/ui-conversation/src/client/chat/Deliverables.tsx b/packages/client/ui-conversation/src/client/chat/Deliverables.tsx deleted file mode 100644 index 7d62e23401..0000000000 --- a/packages/client/ui-conversation/src/client/chat/Deliverables.tsx +++ /dev/null @@ -1,53 +0,0 @@ -// Deliverables: the produced-file row a finished turn ends with. The paths come -// from the mutation tools' follow-along locations (see turnDeliverables), never -// from the closing prose, so the answer carries its own output whether or not -// the model remembered to name it. Clicking one goes through the same openFile -// the tool rows use — the Host's own opener, on the Host machine. - -import type { ChatViewSlotProps } from '../contract/slots.ts' -import css from './Deliverables.module.css' - -/** Files past this stay counted but unlisted: a refactor turn must not bury the answer. */ -const SHOWN = 6 - -/** Trailing path segment, the part that identifies the file at a glance. */ -function basename(path: string): string { - const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) - return at === -1 ? path : path.slice(at + 1) -} - -/** - * Render one turn's produced files as openable chips. - * @param props - the turn's paths (tool order, already deduped), the chat - * view's file opener, and the owning view's locale seat. - * @returns The row, or `null` when the turn produced nothing. - */ -export function Deliverables({ paths, openFile, t }: { - paths: readonly string[] - openFile: (path: string) => void - t: ChatViewSlotProps['t'] -}) { - if (paths.length === 0) return null - const shown = paths.slice(0, SHOWN) - const hidden = paths.length - shown.length - return ( - <div className={css.root}> - <span className={css.label}>{t('produced.label')}</span> - {shown.map(path => ( - <button - key={path} - type="button" - className={css.file} - // The full path is the disambiguator when two turns produce files - // that share a basename; the chip itself stays short. - title={path} - aria-label={t('produced.open', { name: path })} - onClick={() => { openFile(path) }} - > - {basename(path)} - </button> - ))} - {hidden > 0 && <span className={css.more}>{t('produced.more', { count: String(hidden) })}</span>} - </div> - ) -} diff --git a/packages/client/ui-conversation/src/client/chat/chat-flow.ts b/packages/client/ui-conversation/src/client/chat/chat-flow.ts index 7d5e5100f6..57d2ac1bb0 100644 --- a/packages/client/ui-conversation/src/client/chat/chat-flow.ts +++ b/packages/client/ui-conversation/src/client/chat/chat-flow.ts @@ -32,21 +32,6 @@ function rendersNothing(node: ConversationNode): boolean { || ((b.kind === 'text' || b.kind === 'reasoning') && b.text.trim() === '')) } -/** - * Paths a call view reports having created or changed, by render intent rather - * than tool name: a diff card, or a generic card whose kind is `edit` (the - * shape `str_replace_editor`'s insert presents). Every other card produces - * nothing to open — a read looked, a delete removed, a terminal ran. - */ -function producedPaths(view: ToolResultNode['callView']): readonly string[] { - if (view === null) return [] - if (view.card === 'diff') return (view.locations ?? []).map(location => location.path) - if (view.card === 'generic' && view.kind === 'edit') { - return (view.locations ?? []).map(location => location.path) - } - return [] -} - /** * Seq set of assistants that own IconActions: the last content-text assistant * in each turn. Mid-turn narration (text before tools) stays chrome-free. @@ -62,68 +47,6 @@ export function assistantActionsSeqs(nodes: readonly ConversationNode[]): Readon return new Set(lastByTurn.values()) } -/** - * Files each turn produced, keyed by the assistant seq that closes it — the - * same anchor {@link assistantActionsSeqs} elects, so the row lands under the - * message that reports the work rather than after some mid-turn narration. - * - * The source is the mutation tools' own follow-along `locations`, not the - * closing prose: a produced file must be listed whether or not the model - * remembered to name it. A mutation is recognized by render intent, not by - * tool name — a diff card, or a generic card whose `kind` is `edit` (the shape - * `str_replace_editor`'s insert presents) — so a new mutation tool joins by - * declaring what it does. Reads contribute nothing (looking at a file does not - * produce it), and neither do deletes (there is nothing left to open) or - * failed calls. Paths keep first-seen order and appear once, so a file written - * and then edited in the same turn is one entry. - * - * Accumulation resets on the turn boundary, not merely at the closing - * assistant: a turn that mutates files and then ends without content text - * (interrupted mid-tool, or a turn whose last text precedes its last tool - * result) must not spill its paths into the next turn's row, nor leave `seen` - * suppressing a file the next turn legitimately rewrites. - * @param nodes - snapshot nodes (surface order). - * @returns Per-closing-seq produced paths; a turn that produced none is absent. - */ -export function turnDeliverables(nodes: readonly ConversationNode[]): ReadonlyMap<number, readonly string[]> { - const closing = assistantActionsSeqs(nodes) - const byClosingSeq = new Map<number, readonly string[]>() - let pending: string[] = [] - let seen = new Set<string>() - let turn: number | undefined - for (const node of nodes) { - if (node.kind === 'tool-result') { - if (node.isError) continue - for (const path of producedPaths(node.callView)) { - if (seen.has(path)) continue - seen.add(path) - pending.push(path) - } - continue - } - // Tool results carry no turn of their own, so the boundary is read off the - // nodes that do. A user message opens a turn without reporting a number, - // which is why the tracked turn goes back to undefined there: the next - // node to report one is stating the current turn, not entering a new one. - if (node.kind === 'user') { - turn = undefined - pending = [] - seen = new Set() - } else if ('turn' in node) { - if (turn !== undefined && node.turn !== turn) { - pending = [] - seen = new Set() - } - turn = node.turn - } - if (node.kind !== 'assistant' || !closing.has(node.seq)) continue - if (pending.length > 0) byClosingSeq.set(node.seq, pending) - pending = [] - seen = new Set() - } - return byClosingSeq -} - /** * Exact start time of the latest in-window turn without a matching end time. * @param turnTimings - In-window turn timings in event order. diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index a84b4a3bf0..1246433a33 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -3,7 +3,7 @@ import type { ReactNode, RefObject } from 'react' import type { InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook, } from '@deepseek-ai/dsh-client-ui-slots' -import type { CommandNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' +import type { CommandNode, ConversationNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts' import type { createChatStore } from '../stores.ts' @@ -46,6 +46,15 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * registration, and a domain upgrades by registering one row component. */ 'conversation.chat.commandview': { kind: 'keyed'; scope: 'session'; owner: CommandRowOwnerProps } + /** + * The chat view's turn-tail hole: rendered between a closing assistant + * message's body and its IconActions footer, once per turn (the render + * site elects the closing seq). Declared by the chat view entry; feature + * plugins (ui-deliverables' produced-files row) derive what they show + * from the owner currency, and an unregistered hole renders nothing — + * composing such a plugin out of cordis.yml turns its surface off. + */ + 'conversation.chat.turnTail': { kind: 'list'; scope: 'session'; owner: TurnTailOwnerProps } /** * The composer takeover chain: entries are selector-routed replacements * of the default InputBar. Declared by this package's 'conversation' @@ -150,6 +159,24 @@ export interface ConvViewOwnerProps { onInspectDone?: () => void } +/** + * Owner currency of the chat view's turn-tail hole: the finalized snapshot + * and the closing assistant's anchor. Registrants derive their own facts + * from the nodes (the owner never pre-chews a feature's vocabulary), and + * open files through the same opener the tool rows use. + */ +export interface TurnTailOwnerProps { + /** Finalized snapshot nodes in surface order. */ + nodes: readonly ConversationNode[] + /** The closing assistant's seq — the anchor the tail renders under. */ + seq: number + /** + * Open a filesystem path through the Host (tool-row semantics; the chat + * view resolves relative paths against the session cwd). + */ + openFile: (path: string) => void +} + /** * Owner share of a per-view toolview slot: the call material the rendering * view supplies per row. Uniform across views — the trajectory/waterfall @@ -480,7 +507,7 @@ export interface ChatViewInjected { /** Full chat-view component props: runtime & the declared toolview/commandview holes' render share & store & injected & locale seat. */ export type ChatViewSlotProps = - PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.toolview' | 'conversation.chat.commandview'> + PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.toolview' | 'conversation.chat.commandview' | 'conversation.chat.turnTail'> & PropsStore<ChatStore> & ChatViewInjected & PropsLocale<'conversation'> /** diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index ac5f6574c8..725868d57a 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -17,7 +17,7 @@ export type { ComposerChainProps, ConversationInjected, ConversationSessionHeaderInjected, ConversationSessionInjected, ConversationSlotProps, ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps, - EmptyWorkspaceOwnerProps, ToolRowOwnerProps, ToolRowProps, + EmptyWorkspaceOwnerProps, ToolRowOwnerProps, ToolRowProps, TurnTailOwnerProps, } from './contract/slots.ts' // Export discipline: packages/client/AGENTS.md. diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index b489bb29a1..9ba5ed3876 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -85,9 +85,6 @@ export const zh = { 'message.unknownSurface': '未知 surface 事件:{type}', 'message.unknownBlock': '未知内容块', 'message.stopped': '已停止', - 'produced.label': '产物', - 'produced.more': '还有 {count} 个', - 'produced.open': '打开 {name}', 'message.branch': '在新对话中分支', 'message.branchUnavailable': '仅可从已完成轮次的最后一条消息分支', 'message.retry.active': '正在重试模型请求', @@ -228,9 +225,6 @@ export const en = { 'message.unknownSurface': 'Unknown surface event: {type}', 'message.unknownBlock': 'Unknown content block', 'message.stopped': 'Stopped', - 'produced.label': 'Produced', - 'produced.more': '{count} more', - 'produced.open': 'Open {name}', 'message.branch': 'Branch into a new conversation', 'message.branchUnavailable': 'Available only on the last message of a completed turn', 'message.retry.active': 'Retrying model request', diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index dc3e6f5241..4da138d1b5 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -20,7 +20,7 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts import { createChatStore } from '../src/client/stores.ts' import { ChatView } from '../src/client/chat/ChatView.tsx' import { zh } from '../src/client/locales.ts' -import { assistantActionsSeqs, deriveChatFlow, flowKeys, messageBranchSeqs, runningTurnStartTime, turnDeliverables } from '../src/client/chat/chat-flow.ts' +import { assistantActionsSeqs, deriveChatFlow, flowKeys, messageBranchSeqs, runningTurnStartTime } from '../src/client/chat/chat-flow.ts' import { formatRunDuration } from '../src/client/chat/message-chrome.ts' afterEach(() => { @@ -242,93 +242,6 @@ describe('chat-flow derivation', () => { expect([...seqs].sort((a, b) => a - b)).toEqual([5, 7]) }) - it('turnDeliverables attributes each turn’s written files to the assistant that closes it', () => { - const wrote = (seq: number, callId: string, ...paths: string[]): ToolResultNode => ({ - ...toolResult(seq, callId, 'write'), - callView: { - card: 'diff', title: `Write ${paths[0] ?? ''}`, - diffs: paths.map(path => ({ path, oldText: null, newText: 'x' })), - locations: paths.map(path => ({ path })), - }, - }) - const produced = turnDeliverables([ - user(1, 'build it'), - assistant(2, 'writing', 1), - wrote(3, 'a', 'out/index.html'), - // Same file touched twice in one turn is one deliverable, in first-seen order. - wrote(4, 'b', 'out/app.css', 'out/index.html'), - // A read is not a deliverable; a failed write has no file to open. - { ...toolResult(5, 'c', 'read'), callView: { card: 'generic', title: 'Read x', locations: [{ path: 'x.ts' }] } }, - { ...wrote(6, 'd', 'out/broken.html'), isError: true }, - assistant(7, 'done', 1), - user(8, 'again'), - assistant(9, 'second turn', 2), - ]) - expect(produced.get(7)).toEqual(['out/index.html', 'out/app.css']) - // A turn that produced nothing is absent, not an empty row. - expect(produced.has(9)).toBe(false) - // Nothing at all written: no entries. - expect(turnDeliverables([user(1, 'hi'), assistant(2, 'hello', 1)]).size).toBe(0) - }) - - it('turnDeliverables counts a generic edit and never spills across the turn boundary', () => { - const inserted = (seq: number, callId: string, path: string): ToolResultNode => ({ - ...toolResult(seq, callId, 'str_replace_editor'), - // str_replace_editor's insert mutates behind a generic card, so the - // discriminant is the render intent, not the card shape alone. - callView: { card: 'generic', title: `insert ${path}`, kind: 'edit', locations: [{ path }] }, - }) - const wrote = (seq: number, callId: string, path: string): ToolResultNode => ({ - ...toolResult(seq, callId, 'write'), - callView: { - card: 'diff', title: 'Write', diffs: [{ path, oldText: null, newText: 'x' }], locations: [{ path }], - }, - }) - const produced = turnDeliverables([ - user(1, 'insert a line'), - inserted(2, 'i', 'notes.md'), - assistant(3, 'inserted', 1), - // Turn 2 mutates and then ends with no content text (interrupted, or its - // last text preceded the tool): its paths must not ride into turn 3. - user(4, 'now rewrite it'), - wrote(5, 'w', 'leaked.txt'), - user(6, 'and again'), - wrote(7, 'w2', 'notes.md'), - assistant(8, 'done', 3), - ]) - expect(produced.get(3)).toEqual(['notes.md']) - // Turn 3 lists only its own file — and `seen` did not suppress the rewrite - // of a path an earlier turn already touched. - expect(produced.get(8)).toEqual(['notes.md']) - expect([...produced.values()].flat()).not.toContain('leaked.txt') - }) - - it('renders the produced files under the closing message and opens one on click', () => { - const wrote = (seq: number, callId: string, ...paths: string[]): ToolResultNode => ({ - ...toolResult(seq, callId, 'write'), - callView: { - card: 'diff', title: 'Write', - diffs: paths.map(path => ({ path, oldText: null, newText: 'x' })), - locations: paths.map(path => ({ path })), - }, - }) - // Seven files: six chips plus an explicit remainder — the row bounds what - // it shows and says so rather than dropping the rest silently. - const paths = ['deep/a.html', 'b.css', 'c.ts', 'd.ts', 'e.ts', 'f.ts', 'g.ts'] - const h = makeHarness({ - nodes: [user(1, 'build it'), wrote(2, 'w', ...paths), assistant(3, 'done', 1)], - }) - const view = render(<h.ChatView {...h.props} />) - expect(view.getByText('产物')).toBeTruthy() - // Chips carry the basename; the full path stays reachable as the title. - const chip = view.getByRole('button', { name: '打开 deep/a.html' }) - expect(chip.textContent).toBe('a.html') - expect(view.queryByRole('button', { name: '打开 g.ts' })).toBeNull() - expect(view.getByText('还有 1 个')).toBeTruthy() - fireEvent.click(chip) - expect(h.openFile).toHaveBeenCalledWith('deep/a.html') - }) - it('runningTurnStartTime selects the latest turn/start without a turn/end', () => { expect(runningTurnStartTime(new Map([ [1, { startTime: 1_000, endTime: 5_000 }], @@ -790,7 +703,9 @@ describe('ChatView', () => { // Count renderSlot invocations: the memo boundary holds when CallRow does // not re-render, so the row's renderSlot call count freezes during chunks. let rowRenders = 0 - h.props.renderSlot = ((_key: string, _owner: object) => { + h.props.renderSlot = ((key: string, _owner: object) => { + // The turnTail hole renders through the same share; only tool rows count here. + if (key !== 'conversation.chat.toolview') return null rowRenders += 1 return <div data-testid="counting-row" /> }) diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml new file mode 100644 index 0000000000..ee4c23c18c --- /dev/null +++ b/packages/client/ui-deliverables/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/client/ui-deliverables/README.md +README.md: b8b0ea2ef1cbc9b18b905fc08b41278f403ef043 +README.zh.md: a16535b8a8d3625ca1cf90e88c6d9dca742d916b diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md new file mode 100644 index 0000000000..b8b0ea2ef1 --- /dev/null +++ b/packages/client/ui-deliverables/README.md @@ -0,0 +1,21 @@ +# @deepseek-ai/dsh-client-ui-deliverables + +English | [中文](README.zh.md) + +Produced-files feature owner: registers the deliverables row a finished turn ends with into the chat view's `conversation.chat.turnTail` hole. All policy lives here; removing this plugin's line from cordis.yml removes the surface entirely, and the owning view renders an empty hole at zero cost. + +`producedForClosing` derives one turn's produced files from the tail hole's owner currency — the finalized snapshot nodes and the closing assistant's seq. The vocabulary is the mutation tools' own follow-along `locations`, never the closing prose: a produced file is listed whether or not the model remembered to name it. A mutation is recognized by render intent, not tool name — a diff card, or a generic card whose `kind` is `edit` (the shape `str_replace_editor`'s insert presents) — so a new mutation tool joins by declaring what it does. Reads, deletes, and failed calls contribute nothing; a path appears once per turn in first-seen order; accumulation resets on the turn boundary, so a turn that mutates and then ends without content text cannot spill into the next turn's row. + +`ProducedFiles` renders the row between the closing message's body and its IconActions footer: a quiet label, up to six chips (basename text, full path as the `title`), and an explicit remainder count past the cap. Each chip opens through the owner-supplied `openFile` — the same Host opener the tool rows use, with the chat view resolving relative paths against the session cwd. Design rationale: the [workspace file links Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md). + +## Model Experience + +None, as the row is a pure client derivation over already-logged tool metadata and nothing here reaches a model request. + +#### KV Cache effect + +None; this package neither assembles nor sends provider requests. + +## Known Limitations and Deferred Work + +- **Prose mentions stay inert.** An inline-code file name in the closing message does not open the file yet; linking it to the same `locations` vocabulary is the stacked follow-up. diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md new file mode 100644 index 0000000000..a16535b8a8 --- /dev/null +++ b/packages/client/ui-deliverables/README.zh.md @@ -0,0 +1,21 @@ +# @deepseek-ai/dsh-client-ui-deliverables + +[English](README.md) | 中文 + +产物文件的功能属主:把"完成的一轮以其产出文件收尾"的产物行注册进 chat 视图的 `conversation.chat.turnTail` 空位。全部策略都在本包内;从 cordis.yml 中删去本插件那一行即可整体移除该交互面,属主视图以零成本渲染一个空的空位。 + +`producedForClosing` 从 tail 空位的 owner 通货——定稿的快照节点与收尾 assistant 的 seq——推导一轮产出的文件。词表是改写工具自身的跟随 `locations`,绝不是收尾正文:无论模型是否记得点名,产出文件都会被列出。改写按渲染意图识别而非工具名——diff 卡片,或 `kind` 为 `edit` 的 generic 卡片(即 `str_replace_editor` 的 insert 所呈现的形状)——因此新的改写工具靠声明自己做了什么加入。read、删除与失败的调用不贡献任何条目;同一路径在一轮内按首见顺序只出现一次;累积在 turn 边界重置,因此一轮若先改写文件、随后没有正文内容就结束,不会溢进下一轮的行里。 + +`ProducedFiles` 在收尾消息正文与其 IconActions 之间渲染该行:一个安静的标签、至多六枚 chip(文本为文件名,完整路径作为 `title`),超出上限则显示一个明确的剩余计数。每枚 chip 经由 owner 提供的 `openFile` 打开——与工具行相同的 Host 打开器,chat 视图会把相对路径按会话 cwd 解析。设计原理:[workspace 文件链接 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md)。 + +## 模型体验 + +无。该行是对已记录工具元数据的纯客户端派生,这里没有任何内容进入模型请求。 + +#### KV Cache 影响 + +无;该包既不组装也不发送提供方请求。 + +## 已知限制与暂缓事项 + +- **正文提及仍是死文本。**收尾消息里以行内代码写出的文件名尚不能点击打开;把它接到同一份 `locations` 词表是 stacked 的后续工作。 diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json new file mode 100644 index 0000000000..0b5318499b --- /dev/null +++ b/packages/client/ui-deliverables/package.json @@ -0,0 +1,65 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-deliverables", + "description": "Produced-files turn tail: the deliverables row a finished turn ends with", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dshClient": { + "inject": [ + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-conversation" + ], + "platform": "web" + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "BSD-3-Clause", + "dependencies": { + "react": "^18.2.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-client-locale": "^0.0.1", + "@deepseek-ai/dsh-client-runtime": "^0.0.1", + "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1", + "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@types/react": "~18.3.1", + "cordis": "^4.0.0-rc.7" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-conversation/src/client/chat/Deliverables.module.css b/packages/client/ui-deliverables/src/client/ProducedFiles.module.css similarity index 100% rename from packages/client/ui-conversation/src/client/chat/Deliverables.module.css rename to packages/client/ui-deliverables/src/client/ProducedFiles.module.css diff --git a/packages/client/ui-deliverables/src/client/ProducedFiles.tsx b/packages/client/ui-deliverables/src/client/ProducedFiles.tsx new file mode 100644 index 0000000000..609a688586 --- /dev/null +++ b/packages/client/ui-deliverables/src/client/ProducedFiles.tsx @@ -0,0 +1,61 @@ +// ProducedFiles: the produced-file row a finished turn ends with. The paths +// come from the mutation tools' follow-along locations (see +// producedForClosing), never from the closing prose, so the answer carries +// its own output whether or not the model remembered to name it. Clicking one +// goes through the same openFile the tool rows use — the Host's own opener, +// on the Host machine. + +import { useMemo } from 'react' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { producedForClosing } from './turn-deliverables.ts' +import type { NS } from './locales.ts' +import css from './ProducedFiles.module.css' + +/** Files past this stay counted but unlisted: a refactor turn must not bury the answer. */ +const SHOWN = 6 + +/** Trailing path segment, the part that identifies the file at a glance. */ +function basename(path: string): string { + const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + return at === -1 ? path : path.slice(at + 1) +} + +/** Full props: the turn-tail owner currency plus this plugin's locale seat. */ +export type ProducedFilesProps = TurnTailOwnerProps & PropsLocale<typeof NS> + +/** + * Render one turn's produced files as openable chips. + * @param props - the tail hole's owner currency (snapshot nodes, the closing + * assistant's seq, the chat view's file opener) and the locale seat. + * @returns The row, or `null` when the turn produced nothing. + */ +export function ProducedFiles({ nodes, seq, openFile, t }: ProducedFilesProps) { + // Per-closing-message derivation over the windowed snapshot: O(nodes) on + // node-identity change only, which is the same cadence the owning view + // re-derives its own flow at. + const paths = useMemo(() => producedForClosing(nodes, seq), [nodes, seq]) + if (paths.length === 0) return null + const shown = paths.slice(0, SHOWN) + const hidden = paths.length - shown.length + return ( + <div className={css.root}> + <span className={css.label}>{t('produced.label')}</span> + {shown.map(path => ( + <button + key={path} + type="button" + className={css.file} + // The full path is the disambiguator when two turns produce files + // that share a basename; the chip itself stays short. + title={path} + aria-label={t('produced.open', { name: path })} + onClick={() => { openFile(path) }} + > + {basename(path)} + </button> + ))} + {hidden > 0 && <span className={css.more}>{t('produced.more', { count: String(hidden) })}</span>} + </div> + ) +} diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts new file mode 100644 index 0000000000..536c019b01 --- /dev/null +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -0,0 +1,42 @@ +/** + * Deliverables plugin, browser half: registers the produced-files row into + * the chat view's turn-tail hole. All policy lives here — the derivation + * from the mutation tools' `locations`, the chip cap, and the copy — so + * composing this plugin out of cordis.yml removes the surface entirely; the + * owning view renders an empty hole at zero cost. + */ +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from '@deepseek-ai/dsh-client-locale/client' +import { ProducedFiles } from './ProducedFiles.tsx' +import { en, NS, zh, type DeliverablesKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Produced-files row copy. */ + 'deliverables': DeliverablesKey + } +} + +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'] + +/** + * Client plugin body: register the dictionaries and the turn-tail entry. + * @param ctx - client root context. + */ +export function apply(ctx: ClientContext): void { + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-deliverables: dictionaries') + ctx.slots.inject( + 'conversation.chat.turnTail', + () => ctx.slots.register({ + name: 'conversation.chat.turnTail', + id: 'produced-files', + order: 0, + locale: NS, + }, ProducedFiles), + ) +} diff --git a/packages/client/ui-deliverables/src/client/locales.ts b/packages/client/ui-deliverables/src/client/locales.ts new file mode 100644 index 0000000000..aa51aa75a7 --- /dev/null +++ b/packages/client/ui-deliverables/src/client/locales.ts @@ -0,0 +1,21 @@ +/** `deliverables` namespace dictionaries. */ + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'deliverables' + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'produced.label': '产物', + 'produced.more': '还有 {count} 个', + 'produced.open': '打开 {name}', +} + +/** English dictionary (same key set). */ +export const en: Record<DeliverablesKey, string> = { + 'produced.label': 'Produced', + 'produced.more': '{count} more', + 'produced.open': 'Open {name}', +} + +/** Union of this namespace's dictionary keys. */ +export type DeliverablesKey = keyof typeof zh diff --git a/packages/client/ui-deliverables/src/client/turn-deliverables.ts b/packages/client/ui-deliverables/src/client/turn-deliverables.ts new file mode 100644 index 0000000000..faa0455b37 --- /dev/null +++ b/packages/client/ui-deliverables/src/client/turn-deliverables.ts @@ -0,0 +1,78 @@ +/** + * Pure derivation of one turn's produced files from finalized snapshot + * nodes. Client-only and model-free: the vocabulary is the mutation tools' + * own follow-along `locations`, never the closing prose. + */ +import type { ConversationNode, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' + +/** + * Paths a call view reports having created or changed, by render intent rather + * than tool name: a diff card, or a generic card whose kind is `edit` (the + * shape `str_replace_editor`'s insert presents). Every other card produces + * nothing to open — a read looked, a delete removed, a terminal ran. + */ +function producedPaths(view: ToolResultNode['callView']): readonly string[] { + if (view === null) return [] + if (view.card === 'diff') return (view.locations ?? []).map(location => location.path) + if (view.card === 'generic' && view.kind === 'edit') { + return (view.locations ?? []).map(location => location.path) + } + return [] +} + +/** + * Files produced by the turn the assistant at `seq` closes — the anchor the + * render site elects, so the row lands under the message that reports the + * work rather than after some mid-turn narration. + * + * The source is the mutation tools' own follow-along `locations`, not the + * closing prose: a produced file must be listed whether or not the model + * remembered to name it. A mutation is recognized by render intent, not by + * tool name — a diff card, or a generic card whose `kind` is `edit` (the shape + * `str_replace_editor`'s insert presents) — so a new mutation tool joins by + * declaring what it does. Reads contribute nothing (looking at a file does not + * produce it), and neither do deletes (there is nothing left to open) or + * failed calls. Paths keep first-seen order and appear once, so a file written + * and then edited in the same turn is one entry. + * + * Accumulation resets on the turn boundary — a user message, or a node + * reporting a different turn number — so a turn that mutates files and then + * ends without content text cannot spill its paths into the next turn's row, + * nor leave the dedup set suppressing a file the next turn legitimately + * rewrites. Tool results carry no turn of their own; the boundary is read off + * the nodes that do, and a user message resets the tracked turn to undefined + * because the next node to report one is stating the current turn, not + * entering a new one. + * @param nodes - snapshot nodes (surface order). + * @param seq - the closing assistant's seq (the render site's anchor). + * @returns Produced paths in first-seen order; empty when the turn wrote nothing. + */ +export function producedForClosing(nodes: readonly ConversationNode[], seq: number): readonly string[] { + let pending: string[] = [] + let seen = new Set<string>() + let turn: number | undefined + for (const node of nodes) { + if (node.kind === 'tool-result') { + if (node.isError) continue + for (const path of producedPaths(node.callView)) { + if (seen.has(path)) continue + seen.add(path) + pending.push(path) + } + continue + } + if (node.kind === 'user') { + turn = undefined + pending = [] + seen = new Set() + } else if ('turn' in node) { + if (turn !== undefined && node.turn !== turn) { + pending = [] + seen = new Set() + } + turn = node.turn + } + if (node.kind === 'assistant' && node.seq === seq) return pending + } + return [] +} diff --git a/packages/client/ui-deliverables/src/css-modules.d.ts b/packages/client/ui-deliverables/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-deliverables/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record<string, string> + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-deliverables/src/index.ts b/packages/client/ui-deliverables/src/index.ts new file mode 100644 index 0000000000..012876cc2d --- /dev/null +++ b/packages/client/ui-deliverables/src/index.ts @@ -0,0 +1,9 @@ +/** + * Deliverables plugin, node half. Pure UI plugin: the empty apply exists so + * the plugin appears in the host cordis.yml / Loader; the browser half ships + * via exports["./client"], discovered through the package.json dshClient + * declaration. + */ + +/** Host plugin body — no host-side behavior for this surface plugin. */ +export function apply(): void {} diff --git a/packages/client/ui-deliverables/src/invariant.ts b/packages/client/ui-deliverables/src/invariant.ts new file mode 100644 index 0000000000..39c39591cf --- /dev/null +++ b/packages/client/ui-deliverables/src/invariant.ts @@ -0,0 +1,32 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-deliverables`. + * @module @deepseek-ai/dsh-client-ui-deliverables/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-deliverables' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-deliverables-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: one slot registration and one dictionary + * registration, both effect-owned with disposal proven by the HMR-safety + * spec — the plugin emits no cordis events and owns no cross-plugin mutable + * state. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/ui-deliverables/tests/produced-files.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.spec.tsx new file mode 100644 index 0000000000..e5d92424a3 --- /dev/null +++ b/packages/client/ui-deliverables/tests/produced-files.spec.tsx @@ -0,0 +1,183 @@ +// @vitest-environment jsdom +/** + * ui-deliverables browser half: the derivation contract of + * `producedForClosing` over finalized snapshot nodes, the row's rendering + * and opener wiring, and the plugin registrations' fiber-teardown removal + * (HMR safety) against the real SlotsService. + */ +import { Context } from 'cordis' +import { cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import type { + AssistantMessageNode, ConversationNode, ToolResultNode, UserMessageNode, +} from '@deepseek-ai/dsh-client-runtime/client' +import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { ProducedFiles } from '../src/client/ProducedFiles.tsx' +import { producedForClosing } from '../src/client/turn-deliverables.ts' +import { apply, inject } from '../src/client/index.ts' +import { apply as applyNode } from '../src/index.ts' +import { apply as applyInvariant } from '../src/invariant.ts' +import { zh } from '../src/client/locales.ts' + +afterEach(cleanup) + +const user = (seq: number, text: string): UserMessageNode => ({ + kind: 'user', + seq, + time: seq * 1000, + content: [{ type: 'text', text }] as never, + source: null, +}) +const assistant = (seq: number, text: string, turn = 1): AssistantMessageNode => ({ + kind: 'assistant', seq, time: seq * 1_000, turn, step: 1, blocks: [{ kind: 'text', text }], +}) +const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode => ({ + kind: 'tool-result', seq, time: seq * 1_000, callId, + call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` }, + callTime: seq * 1_000 - 500, + content: [], isError: false, callView: null, resultView: null, +}) +const wrote = (seq: number, callId: string, ...paths: string[]): ToolResultNode => ({ + ...toolResult(seq, callId, 'write'), + callView: { + card: 'diff', title: `Write ${paths[0] ?? ''}`, + diffs: paths.map(path => ({ path, oldText: null, newText: 'x' })), + locations: paths.map(path => ({ path })), + }, +}) + +describe('producedForClosing derivation', () => { + it('attributes each turn’s written files to the assistant that closes it', () => { + const nodes: ConversationNode[] = [ + user(1, 'build it'), + assistant(2, 'writing', 1), + wrote(3, 'a', 'out/index.html'), + // Same file touched twice in one turn is one deliverable, in first-seen order. + wrote(4, 'b', 'out/app.css', 'out/index.html'), + // A read is not a deliverable; a failed write has no file to open. + { ...toolResult(5, 'c', 'read'), callView: { card: 'generic', title: 'Read x', locations: [{ path: 'x.ts' }] } }, + { ...wrote(6, 'd', 'out/broken.html'), isError: true }, + assistant(7, 'done', 1), + user(8, 'again'), + assistant(9, 'second turn', 2), + ] + expect(producedForClosing(nodes, 7)).toEqual(['out/index.html', 'out/app.css']) + // A turn that produced nothing yields the empty list, and so does an + // anchor the window does not contain. + expect(producedForClosing(nodes, 9)).toEqual([]) + expect(producedForClosing([user(1, 'hi'), assistant(2, 'hello', 1)], 2)).toEqual([]) + expect(producedForClosing(nodes, 999)).toEqual([]) + }) + + it('counts a generic edit and never spills across the turn boundary', () => { + const inserted = (seq: number, callId: string, path: string): ToolResultNode => ({ + ...toolResult(seq, callId, 'str_replace_editor'), + // str_replace_editor's insert mutates behind a generic card, so the + // discriminant is the render intent, not the card shape alone. + callView: { card: 'generic', title: `insert ${path}`, kind: 'edit', locations: [{ path }] }, + }) + const nodes: ConversationNode[] = [ + user(1, 'insert a line'), + inserted(2, 'i', 'notes.md'), + assistant(3, 'inserted', 1), + // Turn 2 mutates and then ends with no content text (interrupted, or its + // last text preceded the tool): its paths must not ride into turn 3. + user(4, 'now rewrite it'), + wrote(5, 'w', 'leaked.txt'), + user(6, 'and again'), + wrote(7, 'w2', 'notes.md'), + assistant(8, 'done', 3), + ] + expect(producedForClosing(nodes, 3)).toEqual(['notes.md']) + // Turn 3 lists only its own file — and the dedup set did not suppress the + // rewrite of a path an earlier turn already touched. + expect(producedForClosing(nodes, 8)).toEqual(['notes.md']) + expect(producedForClosing(nodes, 8)).not.toContain('leaked.txt') + }) + + it('resets on a turn-number change and skips turnless, viewless, and locationless nodes', () => { + const nodes: ConversationNode[] = [ + user(1, 'go'), + // A turnless surface node neither tracks nor resets the boundary. + { kind: 'unknown', seq: 1.5, time: 1_500, type: 'x', data: null }, + wrote(2, 'w', 'turn-one.txt'), + // A view-less result (window truncation) and cards without locations + // contribute nothing rather than crashing the walk. + toolResult(3, 'plain'), + { ...toolResult(4, 'nl', 'write'), callView: { card: 'diff', title: 'Write', diffs: [] } }, + { ...toolResult(5, 'ge', 'str_replace_editor'), callView: { card: 'generic', title: 'insert', kind: 'edit' } }, + assistant(6, 'mid narration', 1), + // Turn number advances with no user message in the window (truncated + // history): the accumulator must reset all the same. + assistant(7, 'closing', 2), + ] + expect(producedForClosing(nodes, 6)).toEqual(['turn-one.txt']) + expect(producedForClosing(nodes, 7)).toEqual([]) + }) +}) + +describe('ProducedFiles row', () => { + const t = makeTranslate(zh) + + it('renders capped chips with the full path reachable and opens one on click', () => { + // Seven files: six chips plus an explicit remainder — the row bounds what + // it shows and says so rather than dropping the rest silently. + const paths = ['deep/a.html', 'b.css', 'c.ts', 'd.ts', 'e.ts', 'f.ts', 'g.ts'] + const openFile = vi.fn<(path: string) => void>() + const nodes: ConversationNode[] = [user(1, 'build it'), wrote(2, 'w', ...paths), assistant(3, 'done', 1)] + const view = render(<ProducedFiles nodes={nodes} seq={3} openFile={openFile} t={t} />) + expect(view.getByText('产物')).toBeTruthy() + // Chips carry the basename; the full path stays reachable as the title. + const chip = view.getByRole('button', { name: '打开 deep/a.html' }) + expect(chip.textContent).toBe('a.html') + expect(chip.getAttribute('title')).toBe('deep/a.html') + expect(view.queryByRole('button', { name: '打开 g.ts' })).toBeNull() + expect(view.getByText('还有 1 个')).toBeTruthy() + fireEvent.click(chip) + expect(openFile).toHaveBeenCalledWith('deep/a.html') + }) + + it('a turn that produced nothing renders no row at all', () => { + const nodes: ConversationNode[] = [user(1, 'hi'), assistant(2, 'hello', 1)] + const view = render(<ProducedFiles nodes={nodes} seq={2} openFile={() => {}} t={t} />) + expect(view.container.firstChild).toBeNull() + }) +}) + +describe('package shells', () => { + it('the node half mounts inert and the invariant companion registers ownership', async () => { + // The node half is deliberately inert; mounting it must simply not throw. + applyNode() + const registered: string[] = [] + const ctx = new Context() + ctx.provide('invariants') + ctx.set('invariants', { + register: (pkg: string) => { registered.push(pkg); return () => {} }, + } as never) + const dispose = await applyInvariant(ctx) + expect(registered).toEqual(['@deepseek-ai/dsh-client-ui-deliverables']) + expect(dispose).toBeTypeOf('function') + }) +}) + +describe('plugin registration', () => { + it('registers the tail entry and fiber disposal removes it', async () => { + const ctx = new Context() + await ctx.plugin(SlotsService).await() + // The owning view's child declaration, stood up by a bench root entry. + ctx.slots.register({ + name: 'root', + children: { 'conversation.chat.turnTail': { kind: 'list', scope: 'session' } }, + } as never, () => null) + await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await() + + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(1) + + await fiber.dispose() + expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(0) + }) +}) diff --git a/packages/client/ui-deliverables/tsconfig.json b/packages/client/ui-deliverables/tsconfig.json new file mode 100644 index 0000000000..3fa938986f --- /dev/null +++ b/packages/client/ui-deliverables/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../locale" + }, + { + "path": "../runtime" + }, + { + "path": "../ui-conversation" + }, + { + "path": "../ui-slots" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/client/ui-deliverables/tsdown.config.ts b/packages/client/ui-deliverables/tsdown.config.ts new file mode 100644 index 0000000000..ce1a8cefcc --- /dev/null +++ b/packages/client/ui-deliverables/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-deliverables', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 208e43aa5f..e50fa70b2a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -179,6 +179,9 @@ importers: '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:^ version: link:../../packages/client/ui-conversation + '@deepseek-ai/dsh-client-ui-deliverables': + specifier: workspace:^ + version: link:../../packages/client/ui-deliverables '@deepseek-ai/dsh-client-ui-goal': specifier: workspace:^ version: link:../../packages/client/ui-goal @@ -1440,6 +1443,37 @@ importers: specifier: ^18.2.0 version: 18.3.1 + packages/client/ui-deliverables: + dependencies: + react: + specifier: ^18.2.0 + version: 18.3.1 + devDependencies: + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../test-runtime + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/client/ui-goal: devDependencies: '@deepseek-ai/dsh-client-connection': diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 041972cb9f..cb7ffb7460 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -61,6 +61,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = { 'packages/client/ui-layout': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-sidebar': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-conversation': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, + 'packages/client/ui-deliverables': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-slash': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-command': { kind: 'indirect', reason: 'The dispatch paths trigger the host command.execute RPC; each command handler\'s host package owns any model-visible effect.' }, 'packages/client/ui-model': { kind: 'indirect', reason: 'Selection routes session.selectModel; the host snapshots the target at the next prompt-assembly boundary and owns the model-visible effect.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 9ba9ba5d84..94e08da920 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -151,6 +151,7 @@ "@deepseek-ai/dsh-client-ui-layout": ["./packages/client/ui-layout/src"], "@deepseek-ai/dsh-client-ui-sidebar": ["./packages/client/ui-sidebar/src"], "@deepseek-ai/dsh-client-ui-conversation": ["./packages/client/ui-conversation/src"], + "@deepseek-ai/dsh-client-ui-deliverables": ["./packages/client/ui-deliverables/src"], "@deepseek-ai/dsh-client-ui-slash": ["./packages/client/ui-slash/src"], "@deepseek-ai/dsh-client-ui-command": ["./packages/client/ui-command/src"], "@deepseek-ai/dsh-client-ui-model": ["./packages/client/ui-model/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index e1d4088061..03a2b8bb59 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -56,6 +56,7 @@ { "path": "./packages/client/ui-layout" }, { "path": "./packages/client/ui-sidebar" }, { "path": "./packages/client/ui-conversation" }, + { "path": "./packages/client/ui-deliverables" }, { "path": "./packages/client/ui-workspace" }, { "path": "./packages/client/ui-slash" }, { "path": "./packages/client/ui-command" }, From 7aedc02ae32b1f049dfe8a5ca5a61f43a7748837 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 6 Aug 2026 06:39:08 -0700 Subject: [PATCH 037/104] docs: regenerate module graph for ui-deliverables --- docs/module-graph.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/module-graph.md b/docs/module-graph.md index 3ad8ef0b7e..70f2570aa1 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -159,6 +159,7 @@ flowchart TD pkg_client_test_runtime["client-test-runtime"] pkg_client_ui_command["client-ui-command"] pkg_client_ui_conversation["client-ui-conversation"] + pkg_client_ui_deliverables["client-ui-deliverables"] pkg_client_ui_goal["client-ui-goal"] pkg_client_ui_layout["client-ui-layout"] pkg_client_ui_model["client-ui-model"] @@ -840,6 +841,11 @@ flowchart TD pkg_client_ui_command --> pkg_client_ui_slash pkg_client_ui_command --> pkg_client_ui_slots pkg_client_ui_command --> pkg_invariants + pkg_client_ui_deliverables --> pkg_client_locale + pkg_client_ui_deliverables --> pkg_client_runtime + pkg_client_ui_deliverables --> pkg_client_ui_conversation + pkg_client_ui_deliverables --> pkg_client_ui_slots + pkg_client_ui_deliverables --> pkg_invariants pkg_client_ui_goal --> pkg_client_connection pkg_client_ui_goal --> pkg_client_locale pkg_client_ui_goal --> pkg_client_runtime @@ -1243,6 +1249,7 @@ flowchart TD | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | From 8f2168303b246d2f4a988b29dbaae3e5794b2c5a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:00:01 +0800 Subject: [PATCH 038/104] feat(web): add install metadata --- ...06-resolved-theme-color-metadata.i18n.yaml | 6 +++ ...026-08-06-resolved-theme-color-metadata.md | 31 +++++++++++++++ ...-08-06-resolved-theme-color-metadata.zh.md | 31 +++++++++++++++ .../2026-08-06-web-install-manifest.i18n.yaml | 6 +++ .../2026-08-06-web-install-manifest.md | 39 +++++++++++++++++++ .../2026-08-06-web-install-manifest.zh.md | 39 +++++++++++++++++++ apps/web/index.html | 1 + apps/web/public/manifest.webmanifest | 16 ++++++++ apps/web/tests/pwa-manifest.e2e.ts | 27 +++++++++++++ apps/web/tests/settings-chrome.e2e.ts | 36 ++++++++++++++--- packages/client/ui-layout/README.i18n.yaml | 4 +- packages/client/ui-layout/README.md | 2 +- packages/client/ui-layout/README.zh.md | 2 +- .../ui-layout/src/client/theme-presenter.ts | 26 ++++++++++--- packages/client/ui-layout/tests/apply.spec.ts | 10 ++++- .../ui-layout/tests/theme-presenter.spec.ts | 36 +++++++++++++++-- .../host/frontend-static/README.i18n.yaml | 4 +- packages/host/frontend-static/README.md | 2 +- packages/host/frontend-static/README.zh.md | 2 +- packages/host/frontend-static/src/index.ts | 1 + .../tests/frontend-static.spec.ts | 8 +++- 21 files changed, 305 insertions(+), 24 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-06-resolved-theme-color-metadata.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-06-resolved-theme-color-metadata.md create mode 100644 .agents/notes/implemented/feature/2026-08-06-resolved-theme-color-metadata.zh.md create mode 100644 .agents/notes/implemented/feature/2026-08-06-web-install-manifest.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-06-web-install-manifest.md create mode 100644 .agents/notes/implemented/feature/2026-08-06-web-install-manifest.zh.md create mode 100644 apps/web/public/manifest.webmanifest create mode 100644 apps/web/tests/pwa-manifest.e2e.ts diff --git a/.agents/notes/implemented/feature/2026-08-06-resolved-theme-color-metadata.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-resolved-theme-color-metadata.i18n.yaml new file mode 100644 index 0000000000..7550af746a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-resolved-theme-color-metadata.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-06-resolved-theme-color-metadata.md +2026-08-06-resolved-theme-color-metadata.md: 2f7a6f0bde5e75aeb6769939cae54d5319aa5bae +2026-08-06-resolved-theme-color-metadata.zh.md: a6d530f841d5c744fc88831f9cb685d1ab5027b6 diff --git a/.agents/notes/implemented/feature/2026-08-06-resolved-theme-color-metadata.md b/.agents/notes/implemented/feature/2026-08-06-resolved-theme-color-metadata.md new file mode 100644 index 0000000000..2f7a6f0bde --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-resolved-theme-color-metadata.md @@ -0,0 +1,31 @@ +# Agent Note: Resolved theme color metadata + +Status: implemented + +English | [中文](2026-08-06-resolved-theme-color-metadata.zh.md) + +## Problem + +The web client can resolve its theme independently of the operating-system preference, so a single manifest `theme_color` or media-qualified static metadata can disagree with an explicit Light or Dark selection. Browser chrome around an installed or ordinary page then need not match the app surface even though the layout presenter already owns the resolved document palette. + +## Decision + +The ui-layout `ThemePresenter` owns one `<meta name="theme-color">` alongside its root `color-scheme`, dark-palette attribute, and inline token writes. After applying a resolved snapshot's palette and token overrides, the presenter reads the body's computed `background-color` into the metadata element and inserts that single node into the document head. Subsequent snapshots update the same node, and disposal removes it. + +The rendered body background remains the color authority. The PWA manifest carries no static `theme_color` or `background_color`, and `ThemeDefinition` gains no second color field that could drift from the token palette. This also lets a registered theme's base-background token reach browser UI through the same application path as its page surface. + +## Verification + +The presenter unit contract covers light and dark computed colors, node reuse, and disposal. The ui-layout composition test covers initial insertion, event-driven reuse, and fiber cleanup. The Web browser settings scenario drives Light, Dark, System, operating-system changes, and reload through the shipped composition, asserting one metadata element whose content equals the computed body background with no console errors. The metadata change has no rendered accessibility-tree output, so the existing scenario golden remains unchanged. + +## Alternatives considered + +**Set `theme_color` in the manifest.** A manifest provides one app-wide value, so either built-in palette can disagree with it; the manifest deliberately omits the field. + +**Declare light and dark metadata with `prefers-color-scheme` media queries.** Media queries follow the operating system, not an explicit in-app selection, and therefore cannot represent the resolved preference. + +**Add a `themeColor` field to every `ThemeDefinition`.** A separate value gives custom themes an independent browser-chrome choice, but duplicates the base-background color and permits the page and surrounding UI to drift. A distinct field can be introduced if a supported theme needs that intentional difference. + +## Consequences + +Supporting browsers update surrounding UI after the client applies its initial resolved snapshot and after every theme change; browsers without `theme-color` support ignore the metadata. Because the value comes from computed presentation, the client must keep a concrete body background. The presenter creates and removes its own node, while unrelated head metadata remains untouched. diff --git a/.agents/notes/implemented/feature/2026-08-06-resolved-theme-color-metadata.zh.md b/.agents/notes/implemented/feature/2026-08-06-resolved-theme-color-metadata.zh.md new file mode 100644 index 0000000000..a6d530f841 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-resolved-theme-color-metadata.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 基于解析后主题的颜色元数据 + +Status: implemented + +[English](2026-08-06-resolved-theme-color-metadata.md) | 中文 + +## 问题 + +Web 客户端可以独立于操作系统偏好解析主题,因此 manifest(元数据清单)中单一的 `theme_color` 值或带媒体条件的静态元数据可能与显式选择的 Light 或 Dark 不一致。此时,无论是已安装页面还是普通页面,其周围的浏览器界面都未必与应用界面一致,尽管布局呈现器已经拥有解析后的 document 调色板。 + +## 决策 + +ui-layout 的 `ThemePresenter` 拥有一个 `<meta name="theme-color">`,与根元素上的 `color-scheme`、深色调色板属性和内联 token 写入并列。在应用解析后快照的调色板与 token 覆盖值之后,呈现器读取 body 计算样式中的 `background-color`,写入该元数据元素,再将该节点插入 document head。后续快照会更新同一节点,资源释放时则移除它。 + +渲染后的 body 背景仍是颜色真源。PWA manifest 不包含静态 `theme_color` 或 `background_color`,`ThemeDefinition` 也不新增可能与 token 调色板偏离的第二个颜色字段。这样一来,注册主题的基础背景 token 也能通过页面界面使用的同一条应用路径作用于浏览器界面。 + +## 验证 + +呈现器的单元测试契约覆盖浅色和深色模式下的计算颜色、节点复用及资源释放。ui-layout 组合测试覆盖初始插入、事件驱动的复用和 fiber 清理。Web 浏览器设置场景通过实际交付的组合依次驱动 Light、Dark、System、操作系统偏好变化和重新加载,并断言页面始终只有一个元数据元素,其内容等于计算后的 body 背景且控制台无错误。这项元数据变更不会出现在渲染后的无障碍树输出中,因此场景现有的预期输出保持不变。 + +## 曾考虑的替代方案 + +**在 manifest 中设置 `theme_color`。** manifest 只能提供一个适用于整个应用的值,因此任一内置调色板都可能与之不一致;manifest 有意省略该字段。 + +**用 `prefers-color-scheme` 媒体查询声明浅色和深色元数据。** 媒体查询跟随操作系统,而非应用内显式选择,因此无法表示解析后的偏好。 + +**为每个 `ThemeDefinition` 添加 `themeColor` 字段。** 单独的值可让自定义主题独立选择浏览器界面配色,但会复制基础背景色,并允许页面与周围的浏览器界面发生偏离。如果受支持的主题需要这种有意差异,可以再引入独立字段。 + +## 后果 + +支持该元数据的浏览器会在客户端应用初始解析后快照及之后每次主题变化时更新周围界面;不支持 `theme-color` 的浏览器会忽略这项元数据。由于该值来自计算后的呈现结果,客户端必须确保 body 始终有明确的背景色。呈现器会创建并移除自己的节点,head 中无关的元数据则保持不变。 diff --git a/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.i18n.yaml new file mode 100644 index 0000000000..d13ede02d2 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.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-06-web-install-manifest.md +2026-08-06-web-install-manifest.md: d400c6e586f4b735fa8e3dcc4899c97e45ac89c1 +2026-08-06-web-install-manifest.zh.md: a7fee0248261e8d0597bb773d4f390973147337b diff --git a/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.md b/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.md new file mode 100644 index 0000000000..d400c6e586 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.md @@ -0,0 +1,39 @@ +# Agent Note: Web install manifest metadata + +Status: implemented + +English | [中文](2026-08-06-web-install-manifest.zh.md) + +## Problem + +The Web build has a document title and favicon but no manifest from which a browser can discover a stable installed identity, launch boundary, or installed presentation. Adding that metadata can also imply capabilities the app does not provide: a service worker suggests an offline contract, while a single language or palette value misrepresents a bilingual UI with resolved light and dark themes. + +## Decision + +The Web entry links `/manifest.webmanifest`, which Vite copies from `apps/web/public/` into the production build. The manifest names the product `DeepSeek Harness`, gives installed chrome the compact name `DSH`, and fixes `id`, `start_url`, and `scope` at `/`. It requests `display: "fullscreen"` so supporting browsers can give the installed editor-like surface the available display area while leaving ordinary tabs unchanged; browsers may apply user overrides or fall back to another display mode. Its icon entry reuses `/favicon.svg` as an SVG of size `any` and purpose `any`. + +This follows code-server's fullscreen choice without copying its `window-controls-overlay` display override. DSH has no custom title bar or layout around native window controls, so such an override would supersede fullscreen without owning the required safe layout. + +The manifest deliberately has no `lang`, `theme_color`, or `background_color`. The product surface is bilingual rather than owned by one manifest language, and either static color can disagree with one of the resolved app palettes. Theme metadata therefore remains outside the install manifest. + +This feature adds no service worker, cache policy, or offline fallback. The manifest supplies install metadata only; browser eligibility and install affordances remain browser policy. The shipped [`dsh-frontend-static`](../../../../packages/host/frontend-static/README.md) fallback recognizes `.webmanifest` as `application/manifest+json` so the same asset is valid through the shipped HTTP composition rather than only in Vite's output directory. + +## Verification + +The built-Web test parses the emitted manifest and pins the complete metadata object, including the human-visible name, compact name, icon, root identity, launch boundary, and display mode, while also verifying that the production `index.html` retains the link. The `dsh-frontend-static` real Loader composition test serves a `.webmanifest` fixture and pins its `application/manifest+json` media type. + +## Alternatives considered + +**Add a service worker and call the app offline-capable.** Rejected because caching the shell without defining session transport, invalidation, failure behavior, and upgrade semantics would create a misleading partial offline contract. + +**Declare one `lang`.** Rejected because no single language describes the bilingual product surface; omission avoids claiming that one locale owns the installed experience. + +**Choose one static background and theme color.** Rejected because the app resolves light and dark palettes at runtime, so either fixed value is knowingly wrong for one supported state. + +**Ship raster and maskable icon variants immediately.** Rejected until a supported installation target demonstrates a requirement the existing scalable favicon cannot meet. New variants remain an additive manifest change rather than a prerequisite for exposing the current identity. + +**Assert only root and display fields in the built artifact.** Rejected because dropping or changing the product name, compact name, or icon is also a shipped install regression. The test intentionally requires an explicit edit whenever any manifest metadata changes. + +## Consequences + +Supporting browsers can discover a stable root-scoped installed identity and fullscreen preference without the application promising offline behavior. Deploying this build below a path prefix requires revisiting the absolute link, identity, launch, scope, and icon URLs together. Browser-specific icon requirements may add variants later, and every intentional metadata change updates the exact built-artifact contract. diff --git a/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.zh.md b/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.zh.md new file mode 100644 index 0000000000..a7fee02482 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.zh.md @@ -0,0 +1,39 @@ +# Agent Note: Web 安装 manifest 元数据 + +Status: implemented + +[English](2026-08-06-web-install-manifest.md) | 中文 + +## 问题 + +Web 构建产物已有文档标题和 favicon,却没有可供浏览器发现稳定安装身份、启动边界或安装后呈现方式的 manifest(元数据清单)。添加这类元数据也可能暗示应用并不具备的能力:service worker 会让人以为应用提供离线契约,而单一语言或调色板取值会错误描述这个能够解析浅色与深色主题的双语 UI。 + +## 决策 + +Web 入口链接 `/manifest.webmanifest`,Vite 会将其从 `apps/web/public/` 复制到生产构建产物。manifest 将产品命名为 `DeepSeek Harness`,为安装后的浏览器界面提供简称 `DSH`,并把 `id`、`start_url` 和 `scope` 固定为 `/`。它请求 `display: "fullscreen"`,使支持这一模式的浏览器能够把可用显示区域交给安装后的编辑器式界面,同时不改变普通标签页;浏览器可以应用用户覆盖设置,或回退到其他显示模式。其图标条目复用 `/favicon.svg`,将它作为尺寸为 `any`、用途为 `any` 的 SVG。 + +这一选择沿用了 code-server 的全屏方案,但没有照搬其 `window-controls-overlay` 显示覆盖项。DSH 没有自定义标题栏,也没有围绕原生窗口控件安排布局,因此使用这类覆盖项会在未落实所需安全布局的情况下取代全屏模式。 + +manifest 有意不包含 `lang`、`theme_color` 或 `background_color`。产品界面支持双语,并不由 manifest 中的单一语言定义;任一静态颜色值都可能与应用解析后的一套调色板不一致。因此,主题元数据仍放在安装 manifest 之外。 + +该功能不添加 service worker、缓存策略或离线回退。manifest 只提供安装元数据;是否具备安装资格、是否提供安装入口仍由浏览器策略决定。实际交付的 [`dsh-frontend-static`](../../../../packages/host/frontend-static/README.md) 回退将 `.webmanifest` 识别为 `application/manifest+json`,因此同一资产经实际交付的 HTTP 组合提供时同样有效,而不只在 Vite 输出目录中有效。 + +## 验证 + +Web 构建产物测试解析输出的 manifest,并固定完整的元数据对象,包括面向用户显示的名称、简称、图标、根路径身份、启动边界和显示模式,同时验证生产构建的 `index.html` 仍保留该链接。`dsh-frontend-static` 的真实 Loader 组合测试提供一个 `.webmanifest` fixture(测试前置数据),并固定其 `application/manifest+json` 媒体类型。 + +## 曾考虑的替代方案 + +**添加 service worker,并宣称应用支持离线。** 不予采纳,因为只缓存应用外壳,却不定义会话传输、失效策略、失败行为和升级语义,会形成具有误导性的不完整离线契约。 + +**声明单一的 `lang`。** 不予采纳,因为没有任何一种语言足以描述双语产品界面;省略该字段可避免声称安装后的体验由某一种区域设置独占。 + +**选择一组静态背景色和主题色。** 不予采纳,因为应用会在运行时解析浅色和深色调色板,因此选择任一固定值,都是明知它与其中一种受支持状态不符。 + +**立即交付光栅和可遮罩图标变体。** 在某个受支持的安装目标证明现有可缩放 favicon 无法满足其要求之前,不予采纳。新变体只是对 manifest 的增量扩展,并非公开当前身份的前提。 + +**只断言构建产物中的根路径字段和显示字段。** 不予采纳,因为产品名称、简称或图标被删除或更改,同样属于已交付安装体验的回归。任何 manifest 元数据发生变化时,测试都有意要求显式改动。 + +## 后果 + +支持这一机制的浏览器可以发现以根路径为作用域的稳定安装身份和全屏偏好,而应用无需承诺离线行为。在路径前缀下部署该构建产物时,必须同时重新审视绝对路径的 manifest 链接,以及身份、启动、作用域和图标 URL。日后可能因浏览器特有的图标要求而新增变体;每一项有意的元数据变更都会同步更新精确的构建产物契约。 diff --git a/apps/web/index.html b/apps/web/index.html index c9fc7d124c..a14de72d40 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -3,6 +3,7 @@ <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> + <link rel="manifest" href="/manifest.webmanifest" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <title>DeepSeek Harness diff --git a/apps/web/public/manifest.webmanifest b/apps/web/public/manifest.webmanifest new file mode 100644 index 0000000000..20a428fee6 --- /dev/null +++ b/apps/web/public/manifest.webmanifest @@ -0,0 +1,16 @@ +{ + "id": "/", + "name": "DeepSeek Harness", + "short_name": "DSH", + "start_url": "/", + "scope": "/", + "display": "fullscreen", + "icons": [ + { + "src": "/favicon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any" + } + ] +} diff --git a/apps/web/tests/pwa-manifest.e2e.ts b/apps/web/tests/pwa-manifest.e2e.ts new file mode 100644 index 0000000000..696e1c7797 --- /dev/null +++ b/apps/web/tests/pwa-manifest.e2e.ts @@ -0,0 +1,27 @@ +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import { expect, it } from 'vitest' + +const DIST_ROOT = fileURLToPath(new URL('../dist', import.meta.url)) + +it('ships install metadata with the built web application', async () => { + const index = await readFile(join(DIST_ROOT, 'index.html'), 'utf8') + expect(index).toContain('') + + const manifest: unknown = JSON.parse(await readFile(join(DIST_ROOT, 'manifest.webmanifest'), 'utf8')) + expect(manifest).toEqual({ + id: '/', + name: 'DeepSeek Harness', + short_name: 'DSH', + start_url: '/', + scope: '/', + display: 'fullscreen', + icons: [{ + src: '/favicon.svg', + sizes: 'any', + type: 'image/svg+xml', + purpose: 'any', + }], + }) +}) diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 43500585d8..e6b10664af 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -1,7 +1,8 @@ // Web e2e scenarios: the settings surface — the modal shell (trigger, nav, // section switching, both close paths), the Appearance preference row (the // real theme gesture — click 深色 and the whole cascade runs: ThemeService preference -> localStorage dsh.theme -// -> theme/change -> ui-layout's presenter -> body attribute -> alias token) +// -> theme/change -> ui-layout's presenter -> body attribute -> alias token + +// browser theme-color metadata) // the Language row (settings-scoped localization + persisted dsh.locale), // the busy-state Enter preference, plus Permission as the persisted default // for subsequently created sessions. @@ -154,17 +155,37 @@ describe('web e2e: settings modal and General preferences', () => { it('flips the theme through the Appearance cubes and persists across reload', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-appearance')) - const readState = async (): Promise<{ attr: boolean; token: string; stored: string | null }> => - await page.evaluate(() => ({ + interface ThemeState { + attr: boolean + background: string + stored: string | null + themeColor: string | null + themeColorCount: number + token: string + } + const readState = async (): Promise => await page.evaluate(() => { + const metas = document.head.querySelectorAll('meta[name="theme-color"]') + const computed = getComputedStyle(document.body) + return { attr: document.body.hasAttribute('data-ds-dark-theme'), - token: getComputedStyle(document.body).getPropertyValue('--dsw-alias-bg-base').trim(), + background: computed.backgroundColor, stored: localStorage.getItem('dsh.theme'), - })) + themeColor: metas[0]?.content ?? null, + themeColorCount: metas.length, + token: computed.getPropertyValue('--dsw-alias-bg-base').trim(), + } + }) + const expectThemeColorSynchronized = (state: ThemeState): void => { + expect(state.themeColorCount).toBe(1) + expect(state.background).not.toBe('rgba(0, 0, 0, 0)') + expect(state.themeColor).toBe(state.background) + } // Pin the OS scheme to light so the default `system` preference resolves // light and the dark flip below is unambiguously the gesture's doing. await page.emulateMedia({ colorScheme: 'light' }) const light = await readState() expect(light.attr).toBe(false) + expectThemeColorSynchronized(light) await page.getByRole('button', { name: '设置', exact: true }).click() const dialog = page.getByRole('dialog', { name: '设置' }) @@ -179,6 +200,7 @@ describe('web e2e: settings modal and General preferences', () => { expect(dark.attr).toBe(true) expect(dark.stored).toBe('dark') expect(dark.token).not.toBe(light.token) + expectThemeColorSynchronized(dark) await page.keyboard.press('Escape') // Reload: the preference survives boot (restore + presenter initial apply). @@ -190,6 +212,7 @@ describe('web e2e: settings modal and General preferences', () => { const reloaded = await readState() expect(reloaded.attr).toBe(true) expect(reloaded.stored).toBe('dark') + expectThemeColorSynchronized(reloaded) // `system` follows the emulated OS scheme (dark stays dark, light clears). await page.getByRole('button', { name: '设置', exact: true }).click() @@ -197,12 +220,15 @@ describe('web e2e: settings modal and General preferences', () => { await systemCube.click() await expect.poll(() => systemCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true') await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(false) + expectThemeColorSynchronized(await readState()) await page.emulateMedia({ colorScheme: 'dark' }) await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(true) + expectThemeColorSynchronized(await readState()) // Restore for the specs that follow: light preference beats the emulated // dark OS scheme, leaving the shared page in the light default. await page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '浅色' }).click() await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(false) + expectThemeColorSynchronized(await readState()) await page.keyboard.press('Escape') expect(tripwire.pageErrors).toEqual([]) }, 90_000) diff --git a/packages/client/ui-layout/README.i18n.yaml b/packages/client/ui-layout/README.i18n.yaml index 8b5aff5db5..d04c06b3da 100644 --- a/packages/client/ui-layout/README.i18n.yaml +++ b/packages/client/ui-layout/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-layout/README.md -README.md: 5cb8f01efb2e18109e917225dbce088ea77394af -README.zh.md: 6559fe595a6219b139fe46cf046906fa63636f64 +README.md: fa60520a20ac8a7f25d494879c68efb06a28998f +README.zh.md: 6ca04c56c29a55f84fc7a6399a7feeb81d249899 diff --git a/packages/client/ui-layout/README.md b/packages/client/ui-layout/README.md index 5cb8f01efb..fa60520a20 100644 --- a/packages/client/ui-layout/README.md +++ b/packages/client/ui-layout/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar resize boundary is an invisible hit strip, while the details boundary retains its floating pill; only details shrinks during concession and then auto-closes. A closed sidebar retains a 56px control rail while details closes to zero width. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto the document (`html { color-scheme }` for native UA chrome, `body[data-ds-dark-theme]` from the active color scheme, plus the theme's alias tokens as inline variables on body). +Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar resize boundary is an invisible hit strip, while the details boundary retains its floating pill; only details shrinks during concession and then auto-closes. A closed sidebar retains a 56px control rail while details closes to zero width. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto the document (`html { color-scheme }` for native UA chrome, `body[data-ds-dark-theme]` from the active color scheme, the theme's alias tokens as inline variables on body, and one owned `` whose content follows the computed body background). Measuring after palette and token application keeps the rendered background as the single color authority; disposing the presenter removes its metadata node with its other global writes. AppFrame always mounts the conversation and details columns; a connected Session renders through `SessionProvider`. The transient layout store starts the sidebar at its default width and details closed, and it never reads or writes `localStorage`. Hero and other unselected states also derive a zero rendered details width without changing that stored preference. AppFrame retains the last non-blank Session id across those states: the first Session remains closed, an explicit details action opens the contract default width, returning to the same Session restores its unchanged width, and selecting a different Session closes details before paint. The conversation owner share is empty, while the sidebar owner share contains only `collapsed` and `width`; registrants obtain business data from standard hooks and actions from their own inject faces. diff --git a/packages/client/ui-layout/README.zh.md b/packages/client/ui-layout/README.zh.md index 6559fe595a..6ca04c56c2 100644 --- a/packages/client/ui-layout/README.zh.md +++ b/packages/client/ui-layout/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -外壳插件:三栏 AppFrame(拖动手柄与让步链)加 `ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot,并声明 `sidebar`、`conversation`、`details` 和 `conversation.empty`。侧边栏的缩放边界是不可见命中条带,详情栏边界则保留其浮动胶囊;让步期间只有详情栏会收缩并随后自动关闭。关闭的侧边栏仍保留 56px 控制栏,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 document(用 `html { color-scheme }` 驱动原生 UA 控件,依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为 body 上的内联变量)。 +外壳插件:三栏 AppFrame(拖动手柄与让步链)加 `ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot,并声明 `sidebar`、`conversation`、`details` 和 `conversation.empty`。侧边栏的缩放边界是不可见命中条带,详情栏边界则保留其浮动胶囊;让步期间只有详情栏会收缩并随后自动关闭。关闭的侧边栏仍保留 56px 控制栏,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 document(用 `html { color-scheme }` 驱动原生 UA 控件,依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为 body 上的内联变量,同时拥有一个 ``,其内容随计算后的 body 背景色更新)。在应用调色板和 token 后进行测量,可确保渲染后的背景保持为唯一颜色真源;呈现器在资源释放时会移除其自有的元数据节点,并一并清除其写入的其他全局状态。 AppFrame 始终挂载会话栏和详情栏;已连接 Session 通过 `SessionProvider` 渲染。布局 store 是瞬时状态,侧边栏以默认宽度启动,详情栏则保持关闭,且该 store 从不读写 `localStorage`。hero 和其他未选中状态也会将详情栏的渲染宽度派生为零,但不会改变存储的宽度偏好。AppFrame 会跨越这些状态保留最后一个非 blank 会话 id:首个会话保持关闭;显式打开详情栏的操作会使用契约默认宽度;返回同一会话时恢复其未改变的宽度;选择不同会话时,详情栏会在绘制前关闭。会话 owner share 为空,侧边栏 owner share 只包含 `collapsed` 和 `width`;注册方通过标准钩子获取业务数据,并从各自的 inject 接口获取操作。 diff --git a/packages/client/ui-layout/src/client/theme-presenter.ts b/packages/client/ui-layout/src/client/theme-presenter.ts index 07dc663c54..87e3592798 100644 --- a/packages/client/ui-layout/src/client/theme-presenter.ts +++ b/packages/client/ui-layout/src/client/theme-presenter.ts @@ -1,10 +1,11 @@ /** * Global theme DOM applier: projects the resolved ThemeSnapshot onto the * document — `html { color-scheme }` for native UA chrome (scrollbars, form - * controls), `body[data-ds-dark-theme]` for the token palette, and the active - * theme's alias-token overrides as inline CSS variables on body. Pure DOM - * writes, no React involvement; the presenter only ever retracts what it wrote - * itself, so foreign attributes and inline styles survive apply/dispose. + * controls), `body[data-ds-dark-theme]` for the token palette, the active + * theme's alias-token overrides as inline CSS variables on body, and one + * presenter-owned `meta[name="theme-color"]` for surrounding browser UI. Pure + * DOM writes, no React involvement; the presenter only ever retracts what it + * wrote itself, so foreign attributes, metadata, and inline styles survive. */ import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client' @@ -15,12 +16,22 @@ export const DARK_ATTRIBUTE = 'data-ds-dark-theme' export class ThemePresenter { /** Token names this presenter wrote in the last apply (its retraction set). */ private appliedTokens: string[] = [] + /** The single metadata node this presenter inserts and removes. */ + private readonly themeColorMeta: HTMLMetaElement + + /** Create the presenter-owned metadata node before the first snapshot arrives. */ + constructor() { + this.themeColorMeta = document.createElement('meta') + this.themeColorMeta.name = 'theme-color' + } /** * Project a snapshot onto the document: set root `color-scheme` and the body * palette attribute from `active.colorScheme` (never the id — `system` is * resolved upstream), then replace the previously applied token variables - * with `active.tokens`. + * with `active.tokens`. Browser theme-color metadata follows the computed + * body background after those writes, so the rendered palette remains the + * color authority. * @param snapshot - resolved theme snapshot from ctx.theme. */ apply(snapshot: ThemeSnapshot): void { @@ -35,14 +46,17 @@ export class ThemePresenter { body.style.setProperty(name, value) this.appliedTokens.push(name) } + this.themeColorMeta.content = getComputedStyle(body).backgroundColor + if (!this.themeColorMeta.isConnected) document.head.append(this.themeColorMeta) } - /** Retract everything this presenter wrote: root color-scheme, the palette attribute, and all applied token variables. */ + /** Retract root color-scheme, the palette attribute, token variables, and the owned metadata node. */ dispose(): void { document.documentElement.style.removeProperty('color-scheme') const body = document.body body.removeAttribute(DARK_ATTRIBUTE) for (const name of this.appliedTokens) body.style.removeProperty(name) this.appliedTokens = [] + this.themeColorMeta.remove() } } diff --git a/packages/client/ui-layout/tests/apply.spec.ts b/packages/client/ui-layout/tests/apply.spec.ts index 903591163c..af85c1c5ae 100644 --- a/packages/client/ui-layout/tests/apply.spec.ts +++ b/packages/client/ui-layout/tests/apply.spec.ts @@ -7,7 +7,7 @@ // coverage gate still requires exercised. import { Context } from 'cordis' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { apply as themeApply, inject as themeInject, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' @@ -15,6 +15,10 @@ import { apply, inject, LayoutService } from '@deepseek-ai/dsh-client-ui-layout/ import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-layout' import * as invariant from '@deepseek-ai/dsh-client-ui-layout/invariant' +beforeEach(() => { + document.head.querySelectorAll('meta[name="theme-color"]').forEach((node) => { node.remove() }) +}) + async function bench() { const ctx = new Context() const slotsFiber = ctx.plugin(SlotsService) @@ -65,13 +69,17 @@ describe('ui-layout client apply', () => { // Initial getter application: jsdom has no matchMedia, system resolves light. expect(document.documentElement.style.colorScheme).toBe('light') expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false) + const themeColorMeta = document.head.querySelector('meta[name="theme-color"]') + expect(themeColorMeta).not.toBeNull() const theme = ctx.get('theme') as ThemeService theme.setTheme('dark') expect(document.documentElement.style.colorScheme).toBe('dark') expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(true) + expect(document.head.querySelector('meta[name="theme-color"]')).toBe(themeColorMeta) await fiber.dispose() expect(document.documentElement.style.colorScheme).toBe('') expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false) + expect(themeColorMeta?.isConnected).toBe(false) // Listener is off: further theme changes no longer reach the document. theme.setTheme('light') theme.setTheme('dark') diff --git a/packages/client/ui-layout/tests/theme-presenter.spec.ts b/packages/client/ui-layout/tests/theme-presenter.spec.ts index a14d781e5f..36a4975fc9 100644 --- a/packages/client/ui-layout/tests/theme-presenter.spec.ts +++ b/packages/client/ui-layout/tests/theme-presenter.spec.ts @@ -1,40 +1,68 @@ // @vitest-environment jsdom // ThemePresenter behavior account: root color-scheme and the palette attribute // follow active.colorScheme only, token variables replace the previous apply's -// set, and dispose retracts everything the presenter wrote. +// set, theme-color metadata follows the rendered body background, and dispose +// retracts everything the presenter wrote. -import { beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client' import { DARK_ATTRIBUTE, ThemePresenter } from '@deepseek-ai/dsh-client-ui-layout/src/client/theme-presenter.ts' +const LIGHT_THEME_COLOR = 'rgb(255, 255, 255)' +const DARK_THEME_COLOR = 'rgb(21, 21, 23)' + function snapshot(colorScheme: 'light' | 'dark', tokens: Record = {}): ThemeSnapshot { // The presenter must key off colorScheme, not the id — keep them distinct. const active = { id: `${colorScheme}-test`, colorScheme, tokens } return { preference: colorScheme, active, themes: [active], revision: 1 } } +function clearThemePresentation(): void { + document.head.querySelectorAll('meta[name="theme-color"], style[data-theme-presenter-test]').forEach((node) => { node.remove() }) +} + +function themeColorMeta(): HTMLMetaElement | null { + return document.head.querySelector('meta[name="theme-color"]') +} + beforeEach(() => { + clearThemePresentation() document.documentElement.style.removeProperty('color-scheme') document.body.removeAttribute(DARK_ATTRIBUTE) document.body.removeAttribute('style') + const style = document.createElement('style') + style.dataset.themePresenterTest = '' + style.textContent = ` + body { background-color: ${LIGHT_THEME_COLOR}; } + body[${DARK_ATTRIBUTE}] { background-color: ${DARK_THEME_COLOR}; } + ` + document.head.append(style) }) +afterEach(clearThemePresentation) + describe('ThemePresenter', () => { it('light scheme sets root color-scheme and leaves the dark attribute absent', () => { const presenter = new ThemePresenter() presenter.apply(snapshot('light')) expect(document.documentElement.style.colorScheme).toBe('light') expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false) + expect(themeColorMeta()?.content).toBe(LIGHT_THEME_COLOR) }) - it('dark scheme sets root color-scheme and the attribute; switching to light clears both', () => { + it('dark scheme sets root color-scheme, the attribute, and metadata; switching to light updates one node', () => { const presenter = new ThemePresenter() presenter.apply(snapshot('dark')) + const meta = themeColorMeta() expect(document.documentElement.style.colorScheme).toBe('dark') expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(true) + expect(meta?.content).toBe(DARK_THEME_COLOR) presenter.apply(snapshot('light')) expect(document.documentElement.style.colorScheme).toBe('light') expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false) + expect(themeColorMeta()).toBe(meta) + expect(meta?.content).toBe(LIGHT_THEME_COLOR) + expect(document.head.querySelectorAll('meta[name="theme-color"]')).toHaveLength(1) }) it('applies tokens as inline variables and clears the previous set on theme change', () => { @@ -52,10 +80,12 @@ describe('ThemePresenter', () => { document.body.style.setProperty('--foreign', 'kept') const presenter = new ThemePresenter() presenter.apply(snapshot('dark', { '--dsw-alias-bg': '#111' })) + const meta = themeColorMeta() presenter.dispose() expect(document.documentElement.style.colorScheme).toBe('') expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false) expect(document.body.style.getPropertyValue('--dsw-alias-bg')).toBe('') expect(document.body.style.getPropertyValue('--foreign')).toBe('kept') + expect(meta?.isConnected).toBe(false) }) }) diff --git a/packages/host/frontend-static/README.i18n.yaml b/packages/host/frontend-static/README.i18n.yaml index 07d337775e..9d757aaf6c 100644 --- a/packages/host/frontend-static/README.i18n.yaml +++ b/packages/host/frontend-static/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/frontend-static/README.md -README.md: c3a831abb1060b59e1802d38d5407a29d24e3bb3 -README.zh.md: d4dc71763280a3c88c73de50f63f2615570c7182 +README.md: 82ba5a2cd0937e2c24505648aa1e3daec6bf2ece +README.zh.md: 1130aa7cc241ee5245fceaba0ef66fcf95871e67 diff --git a/packages/host/frontend-static/README.md b/packages/host/frontend-static/README.md index c3a831abb1..82ba5a2cd0 100644 --- a/packages/host/frontend-static/README.md +++ b/packages/host/frontend-static/README.md @@ -16,4 +16,4 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **The starter MIME table is minimal** — extensions beyond the vite-emitted set fall back to `application/octet-stream`; extend the table when an asset class actually ships. +- **The starter MIME table is minimal** — it covers the Vite-emitted asset set plus the shipped PWA manifest; other extensions fall back to `application/octet-stream` until an asset class actually ships. diff --git a/packages/host/frontend-static/README.zh.md b/packages/host/frontend-static/README.zh.md index d4dc717632..1130aa7cc2 100644 --- a/packages/host/frontend-static/README.zh.md +++ b/packages/host/frontend-static/README.zh.md @@ -16,4 +16,4 @@ Web 壳的 SPA dist 服务器:一个函数插件(配置为 `{distIndex}`) ## 已知限制与延期工作 -- **初始 MIME 表很精简**:vite 输出集合以外的扩展名会回退到 `application/octet-stream`;实际发布新的资产类别时再扩展该表。 +- **初始 MIME 表很精简**:它覆盖 Vite 输出的资产集合及实际交付的 PWA manifest;其他扩展名在相应资产类别实际发布前都会回退到 `application/octet-stream`。 diff --git a/packages/host/frontend-static/src/index.ts b/packages/host/frontend-static/src/index.ts index 4d5032c2d2..8bd5b829c1 100644 --- a/packages/host/frontend-static/src/index.ts +++ b/packages/host/frontend-static/src/index.ts @@ -41,6 +41,7 @@ const MIME: Record = { '.svg': 'image/svg+xml', '.json': 'application/json', '.map': 'application/json', + '.webmanifest': 'application/manifest+json', } /** diff --git a/packages/host/frontend-static/tests/frontend-static.spec.ts b/packages/host/frontend-static/tests/frontend-static.spec.ts index e35e54bb05..4f5fa0d2c7 100644 --- a/packages/host/frontend-static/tests/frontend-static.spec.ts +++ b/packages/host/frontend-static/tests/frontend-static.spec.ts @@ -36,6 +36,7 @@ async function loadComposition(): Promise { await writeFile(distIndex, 'shell') await writeFile(join(dist, 'app.js'), 'export {}') await writeFile(join(dist, 'blob.bin'), 'BLOB') + await writeFile(join(dist, 'manifest.webmanifest'), '{}') const configPath = join(root, 'cordis.yml') await writeFile(configPath, [ "- name: '@deepseek-ai/dsh-host-webserver'", @@ -92,8 +93,13 @@ describe('real Loader composition', () => { const server = loaded.httpServer const port = server.port - // Real asset with its MIME type; a live rebuild is served on the next read. + // Real assets with their MIME types; a live rebuild is served on the next read. expect(await request(port, '/app.js')).toMatchObject({ status: 200, type: 'text/javascript; charset=utf-8', body: 'export {}' }) + expect(await request(port, '/manifest.webmanifest')).toMatchObject({ + status: 200, + type: 'application/manifest+json', + body: '{}', + }) await writeFile(join(root!, 'dist', 'app.js'), 'export const rebuilt = true') expect(await request(port, '/app.js')).toMatchObject({ status: 200, body: 'export const rebuilt = true' }) From 716361844c8616a24f38373d9d8d4e80e44752c1 Mon Sep 17 00:00:00 2001 From: Jiaying Ding Date: Fri, 7 Aug 2026 11:54:45 +0800 Subject: [PATCH 039/104] style(web): use single quotes in hero expectations --- apps/web/tests/details-session-lifecycle.e2e.ts | 2 +- apps/web/tests/hmr-live.e2e.ts | 2 +- apps/web/tests/lifecycle-chrome.e2e.ts | 2 +- apps/web/tests/startup-auto-selection.e2e.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts index 5317c39009..3bd781a5ee 100644 --- a/apps/web/tests/details-session-lifecycle.e2e.ts +++ b/apps/web/tests/details-session-lifecycle.e2e.ts @@ -121,7 +121,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false) await page.getByRole('button', { name: /^(?:New session|新.*会话)$/ }).last().click() - await page.getByText("Into the unknown", { exact: false }).waitFor({ timeout: 15_000 }) + await page.getByText('Into the unknown', { exact: false }).waitFor({ timeout: 15_000 }) await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0) expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false) diff --git a/apps/web/tests/hmr-live.e2e.ts b/apps/web/tests/hmr-live.e2e.ts index 385a516e7d..1e8e81909f 100644 --- a/apps/web/tests/hmr-live.e2e.ts +++ b/apps/web/tests/hmr-live.e2e.ts @@ -75,7 +75,7 @@ it('hot-reloads a real client-plugin source edit without refreshing the page', a if (!existsSync(binPath)) throw new Error('HMR browser test needs the built dsh bin; run pnpm run build first') const originalSource = await readFile(sourcePath) const originalBundle = await readFile(bundlePath) - const oldText = "Into the unknown" + const oldText = 'Into the unknown' const sourceNeedle = "'hero.headline': 'Into the unknown'" const newText = `HMR UPDATED ${'x'.repeat(80)}` const updatedSource = originalSource.toString().replace(sourceNeedle, `'hero.headline': '${newText}'`) diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index e9afa95872..90587e9e4c 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -159,7 +159,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () } // The blank frame renders the hero, not the resident composer: the // headline plus the guidance placeholder are the empty state's anchors. - await expect.poll(() => page.getByText("Into the unknown", { exact: false }).count(), { timeout: 15_000 }).toBe(1) + await expect.poll(() => page.getByText('Into the unknown', { exact: false }).count(), { timeout: 15_000 }).toBe(1) const input = page.locator('textarea').first() await input.waitFor({ timeout: 10_000 }) if (MODE !== 'record') { diff --git a/apps/web/tests/startup-auto-selection.e2e.ts b/apps/web/tests/startup-auto-selection.e2e.ts index 7ef43d2861..c93ed04a40 100644 --- a/apps/web/tests/startup-auto-selection.e2e.ts +++ b/apps/web/tests/startup-auto-selection.e2e.ts @@ -145,7 +145,7 @@ describe('web e2e: startup auto-selection', () => { // seat with `visibility:hidden`, which Playwright reports as not visible). await page.waitForSelector(ROOT_PHASE, { timeout: 15_000 }) expect(await page.locator(ROOT_PHASE).first().getAttribute('data-phase')).toBe('hero') - expect(await page.getByText("Into the unknown").isVisible()).toBe(true) + expect(await page.getByText('Into the unknown').isVisible()).toBe(true) expect(await page.locator('textarea').first().isVisible()).toBe(true) releaseHistory() From 09d1b0d27ff43687970d7b70049dae7843ce8ae4 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 7 Aug 2026 12:50:23 +0800 Subject: [PATCH 040/104] test(web): align skill snapshot with turn actions --- apps/web/tests/snapshots/skill-tool-row/ui.expected.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/web/tests/snapshots/skill-tool-row/ui.expected.md b/apps/web/tests/snapshots/skill-tool-row/ui.expected.md index 7a51aae904..fc1f23d484 100644 --- a/apps/web/tests/snapshots/skill-tool-row/ui.expected.md +++ b/apps/web/tests/snapshots/skill-tool-row/ui.expected.md @@ -7,9 +7,6 @@ - text: Load the snapshot-skill skill with the skill tool, then reply DONE. {{date}} {{clock}} - button "Copy": - img -- button "Branch into a new conversation" [disabled]: - - img -- text: Available only on the last message of a completed turn - button "Context injection @deepseek-ai/dsh-system-prompt": - img - img From f2d1a29636cd0f818468342ad7e16827f7c9bb0f Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 7 Aug 2026 13:23:19 +0800 Subject: [PATCH 041/104] feat(apiproxy): make the default model a user setting the picker writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The route a new session starts from was frozen into the gateway's composition entry, so switching models in a conversation reached only that conversation and every later session went back to the shipped default. The gateway now owns an `api-gateway` settings section: the entry is the base layer and the user document layers over it, so `session.selectModel` records an accepted switch as the default for the next session. The write is wholesale rather than a merge — switching to a model with no reasoning effort has to clear a stored one — and a storage failure is reported without undoing the switch, which already applies to its own session. `targetFor` now resolves its tiers on every read instead of seeding once: an explicit selection, else the session's own logged request header, else the live default. That is what keeps a session that has run a turn deriving its route from its log forever after, while a session still blank — New Session reuses one rather than minting another — starts from a default saved after it was created. --- packages/host/apiproxy/src/api-proxy.ts | 80 ++++++++++---- packages/host/apiproxy/src/index.ts | 89 +++++++++++++-- .../apiproxy/tests/api-proxy-approval.spec.ts | 4 +- .../apiproxy/tests/api-proxy-blank.spec.ts | 2 +- .../apiproxy/tests/api-proxy-cold.spec.ts | 22 ++-- .../apiproxy/tests/api-proxy-commands.spec.ts | 2 +- .../apiproxy/tests/api-proxy-config.spec.ts | 2 +- .../apiproxy/tests/api-proxy-fork.spec.ts | 3 +- .../apiproxy/tests/api-proxy-models.spec.ts | 101 +++++++++++++++++- .../tests/api-proxy-projections.spec.ts | 2 +- .../apiproxy/tests/api-proxy-question.spec.ts | 2 +- .../apiproxy/tests/api-proxy-rename.spec.ts | 2 +- .../apiproxy/tests/api-proxy-search.spec.ts | 2 +- .../tests/api-proxy-subagents.spec.ts | 2 +- .../apiproxy/tests/api-proxy-view.spec.ts | 10 +- .../tests/api-proxy-workspace.spec.ts | 3 +- .../host/apiproxy/tests/rpc-schemas.spec.ts | 2 +- .../todo/tool-todo/tests/projection.spec.ts | 2 +- 18 files changed, 274 insertions(+), 58 deletions(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 19fb0fe8a2..709c63e4d0 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -8,7 +8,7 @@ import { mkdir, stat } from 'node:fs/promises' import { join } from 'node:path' import type { Context } from 'cordis' import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent' -import type { Agent, AgentLlmTarget, AgentLlmTargetRef, AgentStatus } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentLlmTarget, AgentLlmTargetRef, AgentOptions, AgentStatus } from '@deepseek-ai/dsh-agent' import { createUserMessage, freezeMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import { errorChain } from '@deepseek-ai/dsh-llm' import type { MessageSource } from '@deepseek-ai/dsh-llm' @@ -329,8 +329,19 @@ function directoryError(error: unknown): RpcError { /** Resolved Host routing and project-directory defaults consumed by the API implementation. */ export interface ApiProxyDefaults { - provider: string - model: string + /** + * The route a session starts from when its own log names none. Read on + * every access rather than captured, so a default saved during this process + * reaches the sessions that have not run a turn yet. + */ + defaultTarget: () => AgentLlmTarget + /** + * Record a selection as the new default. Absent when the deployment stores + * no user settings, in which case a switch stays process-local. A rejection + * is reported and swallowed: the switch already applies to its own session, + * and undoing it because storage failed would be the worse outcome. + */ + persistDefaultTarget?: (target: AgentLlmTarget) => Promise /** Default project directory for new sessions whose create request carries no cwd. */ cwd: string /** Parent directory for name-created workspaces. */ @@ -720,7 +731,11 @@ function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceVie * @returns the ApiProxy implementation. */ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy { - const agentOptions = { provider: defaults.provider, model: defaults.model } + /** The seed route each create/resume declares; re-read so it never goes stale. */ + const agentOptions = (): AgentOptions => { + const { provider, model } = defaults.defaultTarget() + return { provider, model } + } type WebLlmTargetRef = AgentLlmTargetRef & { current: AgentLlmTarget } const targets = new WeakMap() /** Implicit resume of cold sessions, deduplicating concurrent calls (follows the jsonrpc sessionCreations precedent). */ @@ -735,24 +750,39 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro /** * Install or return the session-local target that prompt assembly snapshots. - * Seed order: latest logged request/header, else the host default routing. - * There is no create-time per-session override tier on this wire — if one - * returns (a create-options contribution), it must fold in between the two. + * + * Precedence, resolved on EVERY read rather than seeded once: a selection + * made in this process, else the session's own latest logged request/header, + * else the live host default. Re-reading is what keeps the two tiers honest + * in both directions — a session that has run a turn derives its route from + * its log forever after, so changing the default never retargets it; and a + * session still blank (New Session reuses one rather than minting another) + * starts from a default saved after it was created. There is no create-time + * per-session override tier on this wire — if one returns (a create-options + * contribution), it must fold in between the selection and the log. */ function targetFor(agent: Agent): WebLlmTargetRef { const installed = targets.get(agent) if (installed !== undefined) return installed - const logged = agent.session.requestHeader()?.config + let picked: AgentLlmTarget | undefined const target: WebLlmTargetRef = { - current: logged === undefined - ? { provider: defaults.provider, model: defaults.model } - : { + get current(): AgentLlmTarget { + if (picked !== undefined) return picked + // Incrementally folded by the session, so a per-step read costs + // O(new events) rather than a rescan. + const logged = agent.session.requestHeader()?.config + if (logged === undefined) return defaults.defaultTarget() + return { provider: logged.provider, model: logged.model, ...logged.reasoningEffort === undefined ? {} : { reasoningEffort: logged.reasoningEffort }, - }, + } + }, + set current(next: AgentLlmTarget) { + picked = next + }, assembled: undefined, } installAgentLlmTarget(agent.ctx, target) @@ -1023,7 +1053,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } const handle = await ctx.agents.resume({ resumeSessionId: sessionId, - agentOptions, + agentOptions: agentOptions(), setup: installTarget, }) return handle.agent @@ -1140,7 +1170,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } return (await ctx.agents.resume({ resumeSessionId: sessionId, - agentOptions, + agentOptions: agentOptions(), setup: installTarget, })).agent } @@ -1152,7 +1182,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } return (await ctx.agents.create({ sessionId, - agentOptions, + agentOptions: agentOptions(), meta: { cwd }, setup: installTarget, })).agent @@ -1692,6 +1722,17 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro : { reasoningEffort: resolved.reasoningEffort }, } targetFor(found.agent).current = selected + // A switch is also how this deployment's default is chosen: the next + // session created without one of its own starts here. Sessions that + // have already logged a route are unaffected — they derive from + // their own log (see targetFor). + try { + await defaults.persistDefaultTarget?.(selected) + } catch (error: unknown) { + ctx.logger.warn( + `api-proxy: the model switch applies to this session but was not saved as the default: ${String(error)}`, + ) + } return ok(request, { selected: { ...selected } }) } catch (error: unknown) { return err(request, { @@ -1794,7 +1835,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro parentSession: source.id, seedLength: cut, }, - agentOptions, + agentOptions: agentOptions(), setup: installTarget, }) } catch (error: unknown) { @@ -2179,13 +2220,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro host: { describe(request) { // TODO(step2): version should read apps/cli's package.json; placeholder for now. + const route = defaults.defaultTarget() return Promise.resolve(ok(request, { version: '0.0.1', // Same source as session.create's fallback: the UI's default project // must match where an unspecified-cwd session actually lands. cwd: defaults.cwd, - provider: defaults.provider, - model: defaults.model, + // 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: route.provider, + model: route.model, attachedSessions: ctx.agents.list().length, })) }, diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index e279575ff4..34ce49fc77 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -6,11 +6,20 @@ * (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 also owns the `api-gateway` settings section: the route a + * session starts from when its own log names none. The composition entry is + * the shipped default and the section layers the user's choice over it, so + * switching models in a conversation is what sets the default for the next + * one. Sessions that have already logged a route are never retargeted by it. */ import { resolve } from 'node:path' import { Context, Service } from 'cordis' import z from 'schemastery' +import type { AgentLlmTarget } from '@deepseek-ai/dsh-agent' +import { ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' import type { ApiProxy } from './api/index.ts' import { createApiProxy } from './api-proxy.ts' @@ -29,16 +38,62 @@ declare module 'cordis' { } } -/** Gateway plugin config: host-level agent routing and Workspace creation root. */ -export interface Config { - /** Default provider route for created/resumed agents. */ +/** + * The settings namespace carrying the user's default route. Named for the + * gateway rather than for the package, because this key is what a person reads + * and writes in `settings.yaml`; the row id in a composition happens to match + * but does not determine it. + */ +export const API_GATEWAY_SETTINGS_NAMESPACE = settingsNamespace('api-gateway') + +/** + * The user-settable slice of the gateway config: the route a session starts + * from when its own log names none. `workspaceRoot` is deliberately not part + * of it — that is a launcher fact, not a preference. + */ +export interface DefaultRouteSettings { + /** Default provider route for created agents. */ provider: string /** Default model id. */ model: string + /** Default reasoning effort; absence preserves the adapter/provider default. */ + reasoningEffort?: string +} + +/** Gateway plugin config: host-level agent routing and Workspace creation root. */ +export interface Config extends DefaultRouteSettings { /** Parent directory for name-created Workspaces; defaults to the Host cwd. */ workspaceRoot?: string } +/** + * The default-route fields, as fresh schema instances. Both the plugin config + * and the settings section are built from this one call, so the section stays + * a subset of the config structurally rather than by a comment two people have + * to keep true. + */ +function defaultRouteFields(): { [K in keyof Required]: z } { + return { + provider: z.string().required(), + model: z.string().required(), + reasoningEffort: z.string(), + } +} + +/** Schema of the settings section. */ +const DefaultRouteSchema: z = z.object(defaultRouteFields()) + +/** Project the stored/composed section onto the agent-facing target shape. */ +function routeTarget(settings: DefaultRouteSettings): AgentLlmTarget { + return { + provider: settings.provider, + model: settings.model, + ...settings.reasoningEffort === undefined + ? {} + : { reasoningEffort: ReasoningEffortId(settings.reasoningEffort) }, + } +} + /** * The API gateway service: implements the ApiProxy contract over the composed * host context and provides it as `ctx.apiProxy`. The Host cwd is the default @@ -51,8 +106,7 @@ export class ApiProxyService extends Service implements ApiProxy { ] static Config: z = z.object({ - provider: z.string().required(), - model: z.string().required(), + ...defaultRouteFields(), workspaceRoot: z.string(), }) @@ -72,9 +126,32 @@ export class ApiProxyService extends Service implements ApiProxy { constructor(ctx: Context, config: Config) { super(ctx, 'apiProxy') const cwd = process.cwd() - const api = createApiProxy(ctx, { + // The composition entry is the shipped default; the settings section + // layers the user's own choice over it, and a deployment without a + // settings provider simply keeps the entry. + const entry: DefaultRouteSettings = { provider: config.provider, model: config.model, + ...config.reasoningEffort === undefined ? {} : { reasoningEffort: config.reasoningEffort }, + } + let route: () => DefaultRouteSettings = () => entry + installSettingsSection(ctx, API_GATEWAY_SETTINGS_NAMESPACE, DefaultRouteSchema, entry, { + setSource: (current) => { + route = current + }, + // Nothing registration-level derives from the default: every consumer + // reads it through the thunk at the moment it needs a route. + onChange: () => {}, + }) + const api = createApiProxy(ctx, { + defaultTarget: () => routeTarget(route()), + // Wholesale, never a merge: switching to a model with no reasoning + // effort must clear a stored one, and a merged patch would strand it + // for the next session to fail on. The section holds no secrets, so + // there is nothing a replace can collaterally drop. + persistDefaultTarget: async (target) => { + await ctx.get('settings')?.replace(API_GATEWAY_SETTINGS_NAMESPACE, target) + }, cwd, workspaceRoot: resolve(config.workspaceRoot ?? cwd), }) diff --git a/packages/host/apiproxy/tests/api-proxy-approval.spec.ts b/packages/host/apiproxy/tests/api-proxy-approval.spec.ts index 4833667583..e6555898cd 100644 --- a/packages/host/apiproxy/tests/api-proxy-approval.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-approval.spec.ts @@ -27,7 +27,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy }> { await ctx.plugin(UserInteractionService) await ctx.plugin(AgentRegistry) await ctx.plugin(ApprovalService) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) return { ctx, api } } @@ -217,7 +217,7 @@ describe('approval pending registry', () => { await ctx.plugin(ApprovalService) let api!: ApiProxy const fiber = ctx.plugin(Object.assign((fiberCtx: Context) => { - api = createApiProxy(fiberCtx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + api = createApiProxy(fiberCtx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) }, { inject: ['sessions', 'agents', 'userInteraction', 'approval'] })) await fiber.await() const abort = new AbortController() diff --git a/packages/host/apiproxy/tests/api-proxy-blank.spec.ts b/packages/host/apiproxy/tests/api-proxy-blank.spec.ts index 4f8637068e..4943c051bb 100644 --- a/packages/host/apiproxy/tests/api-proxy-blank.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-blank.spec.ts @@ -35,7 +35,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy; attach: (sessio await ctx.plugin(AgentRegistry) return { ctx, - api: createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }), + api: createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }), attach: (session) => { ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) }, diff --git a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts index 78a67ef642..4b6337ede8 100644 --- a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts @@ -62,7 +62,7 @@ describe('sessions.list cold merge', () => { return undefined }, }) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) const response = await api.sessions.list(request({})) expect(response.result.ok).toBe(true) @@ -90,7 +90,7 @@ describe('attached updatedAt excludes end-seed', () => { await ctx.plugin(SessionStore) await ctx.plugin(UserInteractionService) await ctx.plugin(AgentRegistry) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) // Old work, resumed just now: the log tail would report the pickup. const worked = 1_000_000 @@ -148,7 +148,7 @@ describe('cold history recovery view', () => { inspect: (id: SessionId, signal?: AbortSignal) => coordinator.inspect(id, signal), locate: () => undefined, } as never) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) const history = await api.sessions.history(request({ sessionId, beforeSeq: 2, maxMessages: 10 })) if (!history.result.ok) throw new Error('history failed') @@ -216,7 +216,7 @@ describe('subagent ownership fence', () => { locate: () => undefined, } as never) const resume = vi.spyOn(ctx.agents, 'resume') - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) const history = await api.sessions.history(request({ sessionId })) expect(history.result.ok).toBe(true) @@ -275,7 +275,7 @@ describe('subagent ownership fence', () => { // instead of answering `agent-busy`. const resume = vi.spyOn(ctx.agents, 'resume') .mockRejectedValue(new Error('registry unavailable in this bench')) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) const prompt = await api.sessions.prompt(request({ sessionId, @@ -316,7 +316,7 @@ describe('subagent ownership fence', () => { }) const startingChild = { id: startingSession.id, session: startingSession, status: 'idle', ctx } as Agent ctx.agents.enter(startingChild, parent) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) const stopped = await api.sessions.cancel(request({ sessionId: originChild.id })) expect(stopped.result.ok).toBe(false) @@ -362,7 +362,7 @@ describe('subagent ownership fence', () => { const followup = vi.fn() const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent ctx.agents.register(agent) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) const response = await api.sessions.prompt(request({ sessionId: agent.id, @@ -380,7 +380,7 @@ describe('degenerate composition (no persistence, no factory)', () => { await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) await ctx.plugin(UserInteractionService) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) const listed = await api.sessions.list(request({})) expect(listed.result.ok).toBe(true) @@ -405,7 +405,7 @@ describe('degenerate composition (no persistence, no factory)', () => { list: () => Promise.resolve([]), inspect, } as never) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) const response = await api.sessions.history(request({ sessionId: sid('session-missing') })) expect(response.result.ok).toBe(false) @@ -431,7 +431,7 @@ describe('sessions.prompt synchronous rejection', () => { followup: () => { throw new Error('agent "session-throwing" lifecycle disposed') }, steer: () => { throw new Error('agent "session-throwing" lifecycle disposed') }, } as unknown as Agent) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) for (const mode of ['queue', 'steer'] as const) { const response = await api.sessions.prompt(request({ @@ -475,7 +475,7 @@ describe('sessions.prompt synchronous rejection', () => { ctx.agents.register(child) throw new Error('session id already published') }) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) const models = await api.sessions.models(request({ sessionId })) expect(models.result.ok).toBe(false) diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts index 1ab33897e3..55781a3e77 100644 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts @@ -25,7 +25,7 @@ import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts' import { RpcId } from '../src/api/rpc.ts' import { createApiProxy } from '../src/api-proxy.ts' -const DEFAULTS = { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' } +const DEFAULTS = { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' } function request

(payload: P): RpcRequest

{ return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload } diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts index 54235c0218..c13a66eaec 100644 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts @@ -24,7 +24,7 @@ import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts' import { RpcId } from '../src/api/rpc.ts' import { createApiProxy } from '../src/api-proxy.ts' -const DEFAULTS = { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' } +const DEFAULTS = { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' } let nextRpc = 1 function request

(payload: P): RpcRequest

{ diff --git a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts b/packages/host/apiproxy/tests/api-proxy-fork.spec.ts index 83955f2d8b..fb6f8cdfed 100644 --- a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-fork.spec.ts @@ -82,8 +82,7 @@ function liveAgent( } const api = (ctx: Context) => createApiProxy(ctx, { - provider: 'default-provider', - model: 'default-model', + defaultTarget: () => ({ provider: 'default-provider', model: 'default-model' }), cwd: '/tmp', workspaceRoot: '/tmp', }) diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index c2dfdae7a7..7a9f2b2f86 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -125,7 +125,7 @@ describe('Web session model selection', () => { model: 'private-preview', reasoningEffort: ReasoningEffortId('max'), }) - const api = createApiProxy(ctx, { provider: 'deepseek-official', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', workspaceRoot: '/tmp' }) const catalog = expectValue(await api.sessions.models(request({ sessionId }))) expect(catalog.current).toEqual({ @@ -160,7 +160,7 @@ describe('Web session model selection', () => { it('accepts an advisory-unlisted model, rejects an unavailable provider, and switches only after the next assembly', async () => { const { ctx, agent, sessionId } = await harness() - const api = createApiProxy(ctx, { provider: 'deepseek-official', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', workspaceRoot: '/tmp' }) const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 } const signal = new AbortController().signal @@ -225,4 +225,101 @@ describe('Web session model selection', () => { .toEqual({ provider: 'deepseek-official', model: 'private-preview', reasoningEffort: 'max' }) await ctx.fiber.dispose() }) + + it('reads the host default live for a session whose log names no route', async () => { + const { ctx, sessionId } = await harness() + let stored = { provider: 'deepseek-official', model: 'deepseek-chat' } + const api = createApiProxy(ctx, { + defaultTarget: () => stored, + cwd: '/tmp', + workspaceRoot: '/tmp', + }) + + expect(expectValue(await api.sessions.models(request({ sessionId }))).current) + .toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' }) + // The default moving after the session exists still reaches it: New + // Session reuses a blank session rather than minting another, so a seed + // captured at creation would show the superseded model there. + stored = { provider: 'deepseek-official', model: 'deepseek-reasoner' } + expect(expectValue(await api.sessions.models(request({ sessionId }))).current) + .toEqual({ provider: 'deepseek-official', model: 'deepseek-reasoner' }) + expect(expectValue(await api.host.describe(request({})))) + .toMatchObject({ provider: 'deepseek-official', model: 'deepseek-reasoner' }) + await ctx.fiber.dispose() + }) + + it('keeps a session that logged a route on it when the host default moves', async () => { + const { ctx, sessionId } = await harness({ + provider: 'deepseek-official', + model: 'deepseek-chat', + }) + let stored = { provider: 'deepseek-official', model: 'deepseek-chat' } + const api = createApiProxy(ctx, { + defaultTarget: () => stored, + cwd: '/tmp', + workspaceRoot: '/tmp', + }) + + stored = { provider: 'duplicate', model: 'same' } + expect(expectValue(await api.sessions.models(request({ sessionId }))).current) + .toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' }) + await ctx.fiber.dispose() + }) + + it('saves an accepted selection as the default and survives a storage failure', async () => { + const { ctx, sessionId } = await harness() + const saved: unknown[] = [] + let reject = false + const api = createApiProxy(ctx, { + defaultTarget: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), + persistDefaultTarget: (target) => { + saved.push(target) + return reject ? Promise.reject(new Error('read-only document')) : Promise.resolve() + }, + cwd: '/tmp', + workspaceRoot: '/tmp', + }) + + expectValue(await api.sessions.selectModel(request({ + sessionId, provider: 'deepseek-official', model: 'deepseek-reasoner', reasoningEffort: 'max', + }))) + expect(saved).toEqual([ + { provider: 'deepseek-official', model: 'deepseek-reasoner', reasoningEffort: 'max' }, + ]) + + // A refused selection never becomes anyone's default. + await api.sessions.selectModel(request({ sessionId, provider: 'missing', model: 'model' })) + expect(saved).toHaveLength(1) + + // Storage failing is not the selection failing: the switch already applies + // to this session, so the call still succeeds. + reject = true + const stillAccepted = expectValue(await api.sessions.selectModel(request({ + sessionId, provider: 'deepseek-official', model: 'deepseek-chat', + }))) + expect(stillAccepted.selected).toEqual({ provider: 'deepseek-official', model: 'deepseek-chat', reasoningEffort: 'high' }) + expect(expectValue(await api.sessions.models(request({ sessionId }))).current) + .toEqual({ provider: 'deepseek-official', model: 'deepseek-chat', reasoningEffort: 'high' }) + await ctx.fiber.dispose() + }) + + it('serves a session and its catalog when the stored default names a route that is gone', async () => { + const { ctx, sessionId } = await harness() + const api = createApiProxy(ctx, { + // What a Models-page removal leaves behind: the settings document still + // names the route the user last picked, and nothing serves it. + defaultTarget: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }), + cwd: '/tmp', + workspaceRoot: '/tmp', + }) + + const catalog = expectValue(await api.sessions.models(request({ sessionId }))) + // Passed through rather than repaired: matching no group is precisely what + // makes the composer seat prompt for a selection instead of naming a model + // the deployment cannot reach. + expect(catalog.current).toEqual({ provider: 'deleted-gateway', model: 'deleted-model' }) + expect(catalog.groups.flatMap(group => group.models.map(model => `${group.id}/${model.id}`))) + .not.toContain('deleted-gateway/deleted-model') + await ctx.fiber.dispose() + }) }) diff --git a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts index a1775a8025..c9cb212004 100644 --- a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts @@ -68,7 +68,7 @@ function seedMessages(session: Session, count: number): void { } } -const api = (ctx: Context) => createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) +const api = (ctx: Context) => createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) describe('session.history projections block', () => { it('serves the unit value on the tail page with asOfSeq = last event seq', async () => { diff --git a/packages/host/apiproxy/tests/api-proxy-question.spec.ts b/packages/host/apiproxy/tests/api-proxy-question.spec.ts index e8eaae813f..ee5747039f 100644 --- a/packages/host/apiproxy/tests/api-proxy-question.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-question.spec.ts @@ -13,7 +13,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy }> { await ctx.plugin(UserInteractionService) return { ctx, - api: createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }), + api: createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }), } } diff --git a/packages/host/apiproxy/tests/api-proxy-rename.spec.ts b/packages/host/apiproxy/tests/api-proxy-rename.spec.ts index 15c7361024..2f93cdd9b3 100644 --- a/packages/host/apiproxy/tests/api-proxy-rename.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-rename.spec.ts @@ -68,7 +68,7 @@ function liveAgent(ctx: Context, id: string, turns: number): Session { return session } -const api = (ctx: Context) => createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) +const api = (ctx: Context) => createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) describe('sessions.rename', () => { it('accepts through the composed title service: normalized user-source event, echoed seq', async () => { diff --git a/packages/host/apiproxy/tests/api-proxy-search.spec.ts b/packages/host/apiproxy/tests/api-proxy-search.spec.ts index 57bb05df4f..15a4ae3bf3 100644 --- a/packages/host/apiproxy/tests/api-proxy-search.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-search.spec.ts @@ -27,7 +27,7 @@ vi.mock('node:fs/promises', async (importOriginal) => { }) const sid = (value: string): SessionId => value as SessionId -const defaults = { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' } +const defaults = { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' } function request(query: string): RpcRequest<{ query: string }> { return { rpcId: RpcId(`search-${query}`), payload: { query } } diff --git a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts index c761484da5..feb9ecb073 100644 --- a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts @@ -88,7 +88,7 @@ function bench(options: { ctx.provide('sessionProjections', { snapshot, restore, onChanged: () => () => {} }) ctx.provide('userInteraction', { registerProvider: () => () => {} }) const api = createApiProxy(ctx, { - provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp', + defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp', }) return { api, getAgent, listChildren, inspect, snapshot, restore, followup, parent } } diff --git a/packages/host/apiproxy/tests/api-proxy-view.spec.ts b/packages/host/apiproxy/tests/api-proxy-view.spec.ts index 43083545db..4490c71bc2 100644 --- a/packages/host/apiproxy/tests/api-proxy-view.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-view.spec.ts @@ -105,7 +105,7 @@ async function collect(iterable: AsyncIterable>, count: num describe('mux live view computation', () => { it('attaches the three standard card views, omits view without a presenter, soft-falls on throw', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) const abort = new AbortController() const stream = api.events.mux({ rpcId: RpcId('t-mux'), payload: {} }, abort.signal) const collected = collect(stream, 9, abort) @@ -170,7 +170,7 @@ describe('mux live view computation', () => { it('serves history entries with call/result views, backscan pairing, and soft-falls', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) const session = ctx.sessions.create() // history resolves the agent first; a live structural stub is enough (only // .session is read on this path). @@ -238,7 +238,7 @@ describe('mux live view computation', () => { it('counts only append-origin messages toward maxMessages and keeps compaction provenance whole', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) const session = ctx.sessions.create() ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) session.append('turn/start', { turn: 1 }) @@ -287,7 +287,7 @@ describe('mux live view computation', () => { it('drops a disposed session from the live open-call table (result after dispose gets no view)', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) const abort = new AbortController() const stream = api.events.mux({ rpcId: RpcId('t-mux3'), payload: {} }, abort.signal) @@ -308,7 +308,7 @@ describe('mux live view computation', () => { it('pairs a result after turn/end via the in-memory backscan fallback', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) const abort = new AbortController() const stream = api.events.mux({ rpcId: RpcId('t-mux2'), payload: {} }, abort.signal) const collected = collect(stream, 4, abort) diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index af315ffcd0..aa560bdf58 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -100,8 +100,7 @@ async function harness( // object per harness mirrors the seam's stability contract. ctx.provide('directoryPicker', { capability: () => picker } as never) const api = createApiProxy(ctx, { - provider: 'test', - model: 'test-model', + defaultTarget: () => ({ provider: 'test', model: 'test-model' }), cwd: workspaceRoot, workspaceRoot, ...extras.openPath === undefined ? {} : { openPath: extras.openPath }, diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index b65861c1ae..040fe56ff5 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -274,7 +274,7 @@ describe('sessions domain schemas', () => { 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 }) + const value = hostDescribeValueSchema.parse({ version: '1', cwd: '/x', defaultTarget: () => ({ provider: 'p', model: 'm' }), attachedSessions: 2 }) expect(value.attachedSessions).toBe(2) expect(hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0 }).provider).toBeUndefined() }) diff --git a/packages/todo/tool-todo/tests/projection.spec.ts b/packages/todo/tool-todo/tests/projection.spec.ts index f1932b9955..08cb7d3216 100644 --- a/packages/todo/tool-todo/tests/projection.spec.ts +++ b/packages/todo/tool-todo/tests/projection.spec.ts @@ -45,7 +45,7 @@ async function harness(withTodoTool: boolean): Promise { if (withTodoTool) await ctx.plugin(ToolTodo, { allowParallelInProgress: true }) const session = ctx.sessions.create() ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) return { ctx, session, From e0f9f7a6e66de81c2cc4fdff2a707212e73575f1 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 7 Aug 2026 13:25:46 +0800 Subject: [PATCH 042/104] fix(ui-models): let a hand-declared route set its reasoning effort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The create card omitted the provider-level effort the editor card offers for the same namespace, so a route declared through 添加自定义提供方 gained a setting the moment it was reopened for editing — one the creating user was never shown. Both cards now render one shared control. The field, its vocabulary, and the inherit-means-absent rule live with the control rather than in the editor, which is what stops the two from drifting apart again. --- .../src/client/CustomProviderCard.tsx | 14 ++++ .../ui-models/src/client/ProviderEditor.tsx | 42 +++-------- .../src/client/ReasoningEffortField.tsx | 71 +++++++++++++++++++ .../ui-models/tests/provider-form.spec.tsx | 34 +++++++++ 4 files changed, 130 insertions(+), 31 deletions(-) create mode 100644 packages/client/ui-models/src/client/ReasoningEffortField.tsx diff --git a/packages/client/ui-models/src/client/CustomProviderCard.tsx b/packages/client/ui-models/src/client/CustomProviderCard.tsx index b4c655472a..4bd14d1179 100644 --- a/packages/client/ui-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-models/src/client/CustomProviderCard.tsx @@ -22,6 +22,7 @@ import { EditorFooter } from './EditorFooter.tsx' import { validateDeepSeekModels } from './DeepSeekModelsEditor.tsx' import { ModelListEditor } from './ModelListEditor.tsx' import type { ModelDraft } from './ModelListEditor.tsx' +import { EFFORT_FIELD, ReasoningEffortField } from './ReasoningEffortField.tsx' import { deriveKeyRef, messageOf } from './store.ts' import type { en } from './locales.ts' import styles from './ModelsSection.module.css' @@ -69,6 +70,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { const [baseURL, setBaseURL] = useState('') const [protocol, setProtocol] = useState(protocols[0] ?? '') const [keyDraft, setKeyDraft] = useState('') + const [effort, setEffort] = useState(undefined) const [models, setModels] = useState([]) const [busy, setBusy] = useState(false) const [failure, setFailure] = useState(undefined) @@ -101,6 +103,9 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { apiKeyEnv: keyRef, api: protocol, baseURL, + // Inherit is the field being absent, not an empty string: the schema + // types it as an effort name, and an empty one would fail the write. + ...effort === undefined ? {} : { [EFFORT_FIELD['pi-ai']]: effort }, models: models.map(model => ({ ...model })), } const response = await api.settings.mutate({ @@ -209,6 +214,15 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { onChange={(event) => { setKeyDraft(event.target.value) }} /> + {/* The same control the editor card shows for this namespace: a route + declared here and edited there must offer the same profile. */} + = { - deepseek: ['off', 'high', 'max'], - 'pi-ai': ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'], -} - -/** The draft key the effort select edits, per layout. */ -const EFFORT_FIELD: Record<'deepseek' | 'pi-ai', string> = { - deepseek: 'reasoningEffort', - 'pi-ai': 'reasoning', -} +type EditorLayout = EffortFamily | 'unknown' /** The public DeepSeek endpoint shown as the deepseek base-URL placeholder. */ const DEEPSEEK_PUBLIC_BASE_URL = 'https://api.deepseek.com' @@ -279,7 +269,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { * family as a parameter is what makes `EFFORT_FIELD` total here: an * unknown namespace never reaches this body. */ - const curatedFields = (family: 'deepseek' | 'pi-ai'): ReactNode => { + const curatedFields = (family: EffortFamily): ReactNode => { const effortField = EFFORT_FIELD[family] const customModels = getPath(draft, ['models']) const modelsOverridden = hasPath(draft, ['models']) @@ -333,23 +323,13 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { }} /> -

- {t('effort')} - -
+ { setField(effortField, effort) }} + t={t} + disabled={disabled} + /> {/* Both families edit the same rows through the same contract; only the extras differ — DeepSeek's inherited capacities, pi-ai's endpoint interrogation. */} diff --git a/packages/client/ui-models/src/client/ReasoningEffortField.tsx b/packages/client/ui-models/src/client/ReasoningEffortField.tsx new file mode 100644 index 0000000000..10b696a4ea --- /dev/null +++ b/packages/client/ui-models/src/client/ReasoningEffortField.tsx @@ -0,0 +1,71 @@ +/** + * The provider-level reasoning-effort select, shared by every card that writes + * a provider profile. It lives here rather than inside one card because both + * write the SAME field of the same profile: a route declared without this + * control and then edited with it would offer a setting the creating user was + * never given, which is exactly the drift that put it here. + * + * The value is the profile's own default effort, applied to every model on the + * route unless a request names one; the empty option means "inherit", which on + * the wire is the field being absent rather than an empty string. + */ + +import type { ReactNode } from 'react' +import type { en } from './locales.ts' +import styles from './ModelsSection.module.css' + +/** The adapter families that expose a provider-level effort, and their vocabularies. */ +export type EffortFamily = 'deepseek' | 'pi-ai' + +/** Reasoning vocabularies per family; the empty option means "inherit". */ +export const EFFORT_CHOICES: Record = { + deepseek: ['off', 'high', 'max'], + 'pi-ai': ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'], +} + +/** The profile key each family's effort lives under. */ +export const EFFORT_FIELD: Record = { + deepseek: 'reasoningEffort', + 'pi-ai': 'reasoning', +} + +/** Props of {@link ReasoningEffortField}. */ +export interface ReasoningEffortFieldProps { + /** Which vocabulary to offer. */ + family: EffortFamily + /** Current value; the empty string is the inherit option. */ + value: string + /** Receives the chosen effort, or undefined for inherit. */ + onChange: (effort: string | undefined) => void + /** Section copy. */ + t: (key: keyof typeof en) => string + /** Disable the control (busy or read-only). */ + disabled: boolean +} + +/** + * Render the provider-level reasoning-effort select. + * @param props - family vocabulary, current value, change sink, copy, and disabled state. + * @returns the labelled select. + */ +export function ReasoningEffortField( + { family, value, onChange, t, disabled }: ReasoningEffortFieldProps, +): ReactNode { + return ( +
+ {t('effort')} + +
+ ) +} diff --git a/packages/client/ui-models/tests/provider-form.spec.tsx b/packages/client/ui-models/tests/provider-form.spec.tsx index 99e85b0d10..b35302f78a 100644 --- a/packages/client/ui-models/tests/provider-form.spec.tsx +++ b/packages/client/ui-models/tests/provider-form.spec.tsx @@ -652,6 +652,40 @@ describe('hand-declared providers', () => { expect(set).toHaveBeenCalledWith({ ref: 'ACME_GATEWAY_API_KEY', value: 'gw-key' }) }) + it('offers the same reasoning effort the editor does, and omits it when inherited', async () => { + const { mutate, onClose } = mountCard() + const declare = (): void => { + fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } }) + fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } }) + fireEvent.click(screen.getByRole('button', { name: en.addModel })) + fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } }) + } + declare() + + // The vocabulary is the namespace's, not DeepSeek's — a route declared + // here is edited by the pi-ai layout, which offers exactly these. + const select = screen.getByLabelText(en.effort) as HTMLSelectElement + expect([...select.options].map(option => option.value)) + .toEqual(['', 'off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']) + + fireEvent.change(select, { target: { value: 'high' } }) + fireEvent.click(screen.getByText(en.create)) + await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) }) + expect(firstMutate(mutate).ops[0]).toMatchObject({ + path: ['providers', 'acme'], + value: { reasoning: 'high' }, + }) + + // Inherit is the field being absent: an empty string would fail the schema + // that types this as an effort name. + cleanup() + const second = mountCard() + declare() + fireEvent.click(screen.getByText(en.create)) + await waitFor(() => { expect(second.onClose).toHaveBeenCalledWith(true) }) + expect(firstMutate(second.mutate).ops[0].value).not.toHaveProperty('reasoning') + }) + it('names the blocked gate under the form, and nothing once it is satisfied', () => { mountCard() fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } }) From 7a76365585aac59a8c3ad8f7554cb104162ddf02 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Fri, 7 Aug 2026 13:27:15 +0800 Subject: [PATCH 043/104] feat(web): restyle hero preview badge and grow wide-sidebar settings icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hero Preview tag becomes a superscript mono pill riding the title's top-right (r24, bordered, business-tertiary fill), colored by the new --dsw-alias-label-primary-bluish alias over --dsw-static-blue-900 — the first design-owner-approved addition under the token-sheet authority exception recorded in the ui-theme README. The expanded sidebar trigger now uses the native 16px settings icon instead of the 14px asset. --- .../src/client/skeleton/HeroShell.module.css | 22 +++++++++++-------- .../ui-settings-general/src/client/chrome.tsx | 4 ++-- packages/client/ui-theme/README.i18n.yaml | 4 ++-- packages/client/ui-theme/README.md | 2 +- packages/client/ui-theme/README.zh.md | 2 +- .../ui-theme/src/styles/design-platform.css | 4 ++++ 6 files changed, 23 insertions(+), 15 deletions(-) diff --git a/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css b/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css index 3166a81565..0e730a5b30 100644 --- a/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css @@ -24,12 +24,12 @@ } /* figma 34:10411: fish + title, gap 10, centered; 26/32 wt500. The preview - badge is a product addition outside that source and aligns to the title. */ + badge is a product addition outside that source: a mono superscript pill + riding the title's top-right. */ .headline { display: grid; - grid-template-columns: 34px auto; + grid-template-columns: 34px auto auto; column-gap: 10px; - row-gap: 4px; align-items: center; justify-content: center; font-size: 26px; @@ -44,13 +44,17 @@ } .previewBadge { - grid-row: 2; - grid-column: 2; - justify-self: start; - padding: 0 4px; - border-radius: 4px; + grid-row: 1; + grid-column: 3; + align-self: start; + margin-top: 2px; + margin-left: -3px; + padding: 1px 7px 0; + border: 1px solid var(--dsw-alias-interactive-bg-hover); + border-radius: 24px; background: var(--dsw-alias-state-business-tertiary); - color: var(--dsw-alias-label-primary); + color: var(--dsw-alias-label-primary-bluish); + font-family: var(--ds-font-family-code); font-size: 12px; line-height: 18px; font-weight: 500; diff --git a/packages/client/ui-settings-general/src/client/chrome.tsx b/packages/client/ui-settings-general/src/client/chrome.tsx index 28af15ab2f..9698d90868 100644 --- a/packages/client/ui-settings-general/src/client/chrome.tsx +++ b/packages/client/ui-settings-general/src/client/chrome.tsx @@ -4,7 +4,7 @@ * The shell renders the surrounding chrome (button, nav heading row) and * reads each entry's `label` option for aria text. */ -import { IconSettingsOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconSettingsOutline14, IconSettingsOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import css from './chrome.module.css' @@ -22,7 +22,7 @@ export type HeaderContentProps = PropsRuntime<'settings.header'> & PropsLocale<' export function TriggerContent({ wide, t }: TriggerContentProps) { return ( <> - + {wide ? : } {wide && {t('trigger')}} ) diff --git a/packages/client/ui-theme/README.i18n.yaml b/packages/client/ui-theme/README.i18n.yaml index 76bcbaf608..eb915bff8d 100644 --- a/packages/client/ui-theme/README.i18n.yaml +++ b/packages/client/ui-theme/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-theme/README.md -README.md: 88e21fe214ec806b101050949690283d811be36d -README.zh.md: ba781ba89a62292928a7b05ab94ea1cd930b4f50 +README.md: 648213b258169b2e8869a93d058fcc65aece175e +README.zh.md: b807ebd66253c91dc8b79f01ac4d3b2335682001 diff --git a/packages/client/ui-theme/README.md b/packages/client/ui-theme/README.md index 88e21fe214..648213b258 100644 --- a/packages/client/ui-theme/README.md +++ b/packages/client/ui-theme/README.md @@ -21,4 +21,4 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **Third-party themes are a surface, not a product** — registering one means overriding same-named alias variables; no validation exists that an override set is complete. -- **The token sheets are the sole color authority** — values absent from cssdesign (for example the design's #4176E6 tab blue) are deliberately not appended; the nearest semantic token wins (arbitrated 2026-07-22). +- **The token sheets are the sole color authority** — values absent from cssdesign (for example the design's #4176E6 tab blue) are deliberately not appended; the nearest semantic token wins (arbitrated 2026-07-22). Design-owner-approved additions are the exception and enter as a static step plus a semantic alias in the same change (`--dsw-static-blue-900` / `--dsw-alias-label-primary-bluish`, 2026-08-07). diff --git a/packages/client/ui-theme/README.zh.md b/packages/client/ui-theme/README.zh.md index ba781ba89a..b807ebd662 100644 --- a/packages/client/ui-theme/README.zh.md +++ b/packages/client/ui-theme/README.zh.md @@ -21,4 +21,4 @@ ## 已知限制与暂缓事项 - **第三方主题是表层,不是产品**:注册主题意味着覆盖同名别名变量;目前不会验证一组覆盖是否完整。 -- **token 样式表是颜色值的唯一权威来源**:会有意不补入 cssdesign 中缺失的值(例如设计中的 #4176E6 标签页蓝色);一律采用最接近的语义 token(裁定于 2026-07-22)。 +- **token 样式表是颜色值的唯一权威来源**:会有意不补入 cssdesign 中缺失的值(例如设计中的 #4176E6 标签页蓝色);一律采用最接近的语义 token(裁定于 2026-07-22)。设计负责人批准的新增值是例外:须在同一变更中以一个 static 梯度值加一个语义 alias 的形式进入(`--dsw-static-blue-900` / `--dsw-alias-label-primary-bluish`,2026-08-07)。 diff --git a/packages/client/ui-theme/src/styles/design-platform.css b/packages/client/ui-theme/src/styles/design-platform.css index 3e8710822e..00d9d7106b 100644 --- a/packages/client/ui-theme/src/styles/design-platform.css +++ b/packages/client/ui-theme/src/styles/design-platform.css @@ -17,6 +17,7 @@ body { --dsw-static-blue-600: rgb(37, 99, 235); --dsw-static-blue-75: rgb(229, 240, 255); --dsw-static-blue-800: rgb(30, 64, 175); + --dsw-static-blue-900: rgb(14, 48, 116); --dsw-static-blue-950: rgb(23, 37, 84); --dsw-static-deepseek-100: rgb(228, 237, 253); --dsw-static-deepseek-200: rgb(211, 226, 255); @@ -92,6 +93,7 @@ body[data-ds-dark-theme] { --dsw-static-blue-600: rgb(37, 99, 235); --dsw-static-blue-75: rgb(229, 240, 255); --dsw-static-blue-800: rgb(30, 64, 175); + --dsw-static-blue-900: rgb(14, 48, 116); --dsw-static-blue-950: rgb(23, 37, 84); --dsw-static-deepseek-100: rgb(228, 237, 253); --dsw-static-deepseek-200: rgb(211, 226, 255); @@ -197,6 +199,7 @@ body { --dsw-alias-interactive-bg-hover: rgba(38, 49, 72, 0.06); --dsw-alias-label-caption: var(--dsw-static-neutral-bluish-400); --dsw-alias-label-dimmed: var(--dsw-static-neutral-bluish-200); + --dsw-alias-label-primary-bluish: var(--dsw-static-blue-900); --dsw-alias-label-primary-dimmed: var(--dsw-static-neutral-bluish-950); --dsw-alias-label-primary-foreground: var(--dsw-static-neutral-bluish-00); --dsw-alias-label-primary-inverted: var(--dsw-static-neutral-bluish-00); @@ -287,6 +290,7 @@ body[data-ds-dark-theme] { --dsw-alias-interactive-bg-hover: rgba(255, 255, 255, 0.08); --dsw-alias-label-caption: var(--dsw-static-neutral-bluish-600); --dsw-alias-label-dimmed: var(--dsw-static-neutral-bluish-750); + --dsw-alias-label-primary-bluish: var(--dsw-static-neutral-bluish-50); --dsw-alias-label-primary-dimmed: var(--dsw-static-neutral-bluish-100); --dsw-alias-label-primary-foreground: var(--dsw-static-neutral-bluish-1000); --dsw-alias-label-primary-inverted: var(--dsw-static-neutral-bluish-800); From 96795972040151c756323d8bf05504d387aadb8a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 7 Aug 2026 13:49:47 +0800 Subject: [PATCH 044/104] feat(ui-models): tag the provider rows this deployment declared MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A row's stored profile could not tell a hand-declared gateway from a shipped provider whose models someone narrowed — both look identical from outside the adapter — so the Models page had no way to mark the routes a deployment added itself. The directory entry now carries `declared`, answered by the owning adapter against its own installed catalog, and the page renders a Custom tag from it. Absence stays "this adapter draws no such distinction" rather than "shipped", so a route no adapter claims is labelled neither way. Also records the default-route work's Agent Note and the e2e evidence for all three changes: the composer switch writing the section, and the Models page declaring a route with its own reasoning effort. --- ...default-model-follows-the-picker.i18n.yaml | 6 + ...-08-07-default-model-follows-the-picker.md | 33 +++++ ...-07-default-model-follows-the-picker.zh.md | 33 +++++ apps/web/tests/default-model.e2e.ts | 115 ++++++++++++++++++ apps/web/tests/models-settings.e2e.ts | 45 ++++++- .../models-settings/declared.expected.md | 30 +++++ apps/web/tsconfig.json | 1 + docs/config-catalog.md | 22 +++- docs/core-data-structures/core.i18n.yaml | 4 +- docs/core-data-structures/core.md | 9 ++ docs/core-data-structures/core.zh.md | 9 ++ .../client/connection/src/client/fixture.ts | 7 +- packages/client/ui-models/README.i18n.yaml | 4 +- packages/client/ui-models/README.md | 4 +- packages/client/ui-models/README.zh.md | 4 +- .../src/client/ModelsSection.module.css | 14 +++ .../ui-models/src/client/ModelsSection.tsx | 6 + .../client/ui-models/src/client/locales.ts | 2 + .../ui-models/tests/provider-form.spec.tsx | 52 +++++++- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 12 +- packages/host/apiproxy/README.zh.md | 12 +- packages/host/apiproxy/src/api-proxy.ts | 9 +- packages/host/apiproxy/src/api/llm.schema.ts | 1 + packages/host/apiproxy/src/api/llm.ts | 6 + packages/host/apiproxy/src/index.ts | 37 +++--- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 2 +- packages/llm/llm-pi-ai/README.zh.md | 2 +- packages/llm/llm-pi-ai/src/index.ts | 14 ++- packages/llm/llm-pi-ai/tests/catalog.spec.ts | 11 +- .../llm-pi-ai/tests/dynamic-config.spec.ts | 1 + packages/llm/llm/README.i18n.yaml | 4 +- packages/llm/llm/README.md | 2 +- packages/llm/llm/README.zh.md | 2 +- packages/llm/llm/src/types.ts | 9 ++ tsconfig.host.json | 1 + 38 files changed, 479 insertions(+), 56 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md create mode 100644 .agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md create mode 100644 apps/web/tests/default-model.e2e.ts create mode 100644 apps/web/tests/snapshots/models-settings/declared.expected.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 new file mode 100644 index 0000000000..ba3917c8b7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.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-07-default-model-follows-the-picker.md +2026-08-07-default-model-follows-the-picker.md: 5174b224a17728b65f7fd69d7f72388d50e8e825 +2026-08-07-default-model-follows-the-picker.zh.md: 6ead561b928572a479f2c7b19e409b3845363ed6 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 new file mode 100644 index 0000000000..5174b224a1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md @@ -0,0 +1,33 @@ +# Agent Note: the default model follows the picker + +Status: implemented + +English | [中文](2026-08-07-default-model-follows-the-picker.zh.md) + +## Problem + +The route a new session started from was frozen into the gateway's composition entry (`api-gateway` in the web-app bundle patch). Switching models in a conversation reached that conversation only: the next session went back to the shipped default, and the only way to change it was to hand-edit a `cordis.yml` row and restart. There was no user-settings tier between the composition and the per-session choice. + +## Decision + +`ApiProxyService` registers its `{provider, model, reasoningEffort?}` slice as the `api-gateway` settings section: the composition entry is the `base` layer and `settings.yaml` layers the user's choice over it. `workspaceRoot` stays outside the section — a launcher fact, not a preference. The section schema is picked out of `static Config` rather than restated, because the configuration-catalog generator reads that literal statically and a spread breaks it. + +`session.selectModel` records an accepted switch as the new default. There is no separate gesture: switching models in the composer IS how the default is chosen. The write is `replace`, not `update` — switching to a model with no reasoning effort has to clear a stored one, and a merged patch would strand it for the next session to fail on. A storage failure is logged without undoing the switch, which already applies to its own session, and a deployment with no settings provider keeps the entry with the switch staying process-local. + +`ApiProxyDefaults` carries `defaultTarget()` and `persistDefaultTarget()` closures instead of flat `provider`/`model` fields, so `createApiProxy` needs no knowledge of the settings seam. + +`targetFor` resolves its tiers on **every** read rather than seeding a ref once: an explicit selection in this process, else the session's own latest logged `request/header`, else the live default. Both directions depend on the re-read. A session that has run a turn derives from its log forever after, so changing the default never retargets it. A session still blank starts from a default saved after it was created — which matters because New Session reuses a blank session rather than minting another, so a creation-time seed would show the superseded model in exactly the flow the feature exists for. + +The stored route is not validated against the registry. A default naming a route the Models page has since removed still reaches `session.models` as `current`, matching no advertised group — which is what makes the composer seat's existing fallback prompt for a selection instead of naming a model the deployment cannot reach. + +## Consequences + +`ApiProxyDefaults` changed shape, updating ~40 test construction sites. `host.describe` now reports the live default rather than a captured one, which is what it always meant. `settings.yaml` gains an `api-gateway:` section the moment a user switches models; the `api-gateway` namespace is deliberately NOT added to the gateway's exposed-namespace allowlist, so the Settings page neither reads nor writes it — the model picker is its editor. + +## Alternatives considered + +- **Falling back to the composition entry when the stored route is unregistered.** Rejected: the composer would then name the shipped DeepSeek model instead of prompting, which is both a silent switch to a provider the user did not pick and the opposite of the requested behavior. +- **Validating and clearing a stale default.** Rejected: catalog membership is advisory by design (`buildModelCatalog` documents it), so an adapter may serve a model its own catalog stopped advertising; self-healing would break that deliberate case. +- **A `settings.update` merge patch.** Rejected: it cannot clear `reasoningEffort`, so a switch from a reasoning model to a plain one leaves an effort the next session fails on. +- **Persisting only from blank sessions.** Rejected: the most informative switch is the one made mid-conversation after seeing a model underperform, and that one would never be saved. +- **A separate "set as default" affordance.** Rejected for now: it adds a second gesture for what every comparable product infers from the switch itself. The cost is that a temporary switch in an old session also moves the default. 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 new file mode 100644 index 0000000000..6ead561b92 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 默认模型跟随选择器 + +Status: implemented + +[English](2026-08-07-default-model-follows-the-picker.md) | 中文 + +## 问题 + +新会话的起始路由被冻结在网关的组合条目里(web-app bundle patch 中的 `api-gateway` 行)。在一段对话里切换模型只影响这段对话:下一个会话又回到出厂默认,而要改这个默认值,唯一的办法是手工编辑一条 `cordis.yml` 行并重启。组合层与每会话选择之间没有用户设置这一层。 + +## 决定 + +`ApiProxyService` 把自己的 `{provider, model, reasoningEffort?}` 切片注册为 `api-gateway` 设置段:组合条目是 `base` 层,`settings.yaml` 把用户的选择叠加其上。`workspaceRoot` 留在段外——它是启动器事实,不是偏好。段 schema 从 `static Config` 里挑出来而不是重述一遍,因为配置目录生成器是静态读取那个字面量的,展开语法会让它失败。 + +`session.selectModel` 把被接受的切换记录为新的默认值。没有另一个单独的手势:在输入框切模型**就是**选定默认值的方式。写入用 `replace` 而非 `update`——切到一个不支持推理的模型必须清掉已存的等级,而合并补丁会把它滞留下来,让下一个会话在它上面失败。存储失败只记日志,不撤销这次切换(它对自己所在的会话已经生效);没有设置提供方的部署保留组合条目,切换只停留在进程内。 + +`ApiProxyDefaults` 改为携带 `defaultTarget()` 与 `persistDefaultTarget()` 两个闭包,而不是扁平的 `provider`/`model` 字段,这样 `createApiProxy` 不需要知道设置这条缝的存在。 + +`targetFor` 在**每一次**读取时解析各级,而不是只在创建时种一次 ref:本进程内的显式选择,其次是该会话自己最新记录的 `request/header`,最后才是活的默认值。两个方向都依赖这次重新读取。已经跑过一轮的会话此后永远从自己的日志推导,改默认值不会重定向它;而仍然空白的会话会用上它创建之后才保存的默认值——这一点很关键,因为新建会话是复用空白会话而不是再开一个,创建时种下的值恰好会在这个功能存在的意义所在的流程里显示已被取代的模型。 + +存下来的路由不做注册表校验。默认值指向一条模型页已经删除的路由时,它照样作为 `current` 送到 `session.models`,匹配不到任何已公布的分组——而这正是让输入框选择器已有的回退提示重新选择、而不是显示一个部署根本够不着的模型的原因。 + +## 影响 + +`ApiProxyDefaults` 形状变了,约 40 处测试构造点随之更新。`host.describe` 现在报告的是活的默认值而非捕获的快照,这本就是它一直想表达的含义。用户一旦切换模型,`settings.yaml` 就会多出一个 `api-gateway:` 段;`api-gateway` 这个 namespace 刻意**没有**加进网关的暴露名单,因此设置页既不读也不写它——模型选择器就是它的编辑器。 + +## 考虑过的替代方案 + +- **存下来的路由未注册时回落到组合条目。** 否决:那样输入框会显示出厂的 DeepSeek 模型而不是提示选择,既是静默切到用户没选的提供方,也与要求的行为正好相反。 +- **校验并清空失效的默认值。** 否决:目录成员关系按设计是咨询性的(`buildModelCatalog` 有注释说明),适配器可以服务一个自己目录已不再公布的模型;自动修复会破坏这个刻意保留的情形。 +- **用 `settings.update` 合并补丁。** 否决:它清不掉 `reasoningEffort`,于是从推理模型切到普通模型会留下一个等级,让下一个会话在它上面失败。 +- **只在空白会话里持久化。** 否决:最有信息量的切换恰恰是对话到一半发现模型不行时做的那一次,而它永远存不下来。 +- **单独做一个「设为默认」的入口。** 目前否决:同类产品都从切换本身推断的事情,它却要多一个手势。代价是在老会话里的临时切换也会移动默认值。 diff --git a/apps/web/tests/default-model.e2e.ts b/apps/web/tests/default-model.e2e.ts new file mode 100644 index 0000000000..0ba81e3ca0 --- /dev/null +++ b/apps/web/tests/default-model.e2e.ts @@ -0,0 +1,115 @@ +// Web e2e scenario: switching models in the composer is how this deployment's +// default is chosen. The gesture writes the `api-gateway` settings section, a +// session created afterwards starts from it, and a session that already logged +// a route keeps deriving from its own log — the tier order the gateway +// resolves on every read. +// Zero model calls: the switch is settings/llm-domain traffic only, so there +// is no fixture and a stray stream would fail loud on the open seam. A second +// route is declared host-side (not through the UI, which has its own +// scenario) purely so the picker has somewhere to switch to: the keyless +// replay catalog publishes a single model. +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { SessionId } from '@deepseek-ai/dsh-session' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts' +import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts' + +/** The route declared for this scenario, and the model the switch lands on. */ +const ROUTE = 'acme-gateway' +const MODEL = 'acme-large' + +describe('web e2e: the composer model switch is the default for later sessions', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + /** Create one session and its agent through the same wire face the browser uses. */ + const createSession = async (sessionId: string): Promise => { + const response = await scaffold.ctx.apiProxy.sessions.create({ + rpcId: `default-model-create-${sessionId}` as never, + payload: { sessionId: SessionId(sessionId), cwd: scaffold.workspaceCwd }, + }) + if (!response.result.ok) throw new Error(`session.create failed: ${response.result.error.message}`) + return response.result.value.sessionId + } + + /** The route the gateway reports for one session, through the real wire face. */ + const currentOf = async (sessionId: string): Promise => { + const response = await scaffold.ctx.apiProxy.sessions.models({ + rpcId: `default-model-${sessionId}` as never, + payload: { sessionId: SessionId(sessionId) }, + }) + if (!response.result.ok) throw new Error(`session.models failed: ${response.result.error.message}`) + return response.result.value.current + } + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + // A second route so the picker has two models. Declared through the + // settings seam rather than the Models page: this scenario is about the + // composer, and the declaring flow is covered by models-settings.e2e. + await scaffold.ctx.settings.update(settingsNamespace('llm-pi-ai'), { + providers: { + [ROUTE]: { + displayName: 'Acme Gateway', + api: 'openai-completions', + baseURL: 'https://gateway.acme.example/v1', + models: [{ id: MODEL, name: 'Acme Large' }], + }, + }, + }) + browser = await chromium.launch() + page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + // The composer's seats only exist once a workspace is connected: without + // one the input is the locked placeholder and no session scope is open. + await connectFreshWorkspaceZh(page, scaffold.workspaceCwd) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('writes the switched model as the default and leaves a logged session alone', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-default-model')) + // A session that has already run a turn, spelled as the fact a turn + // leaves behind: its own logged route. + const loggedId = await createSession('default-model-logged') + scaffold.ctx.sessions.get(SessionId(loggedId))?.append('request/header', { + header: { config: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } }, + reason: 'initial', + }) + + const trigger = page.getByRole('button', { name: /^选择模型/ }) + await trigger.waitFor({ timeout: 15_000 }) + await trigger.click() + await page.getByRole('menuitem', { name: /模型/ }).click() + await page.getByRole('menuitemradio', { name: 'Acme Large' }).click() + + // The switch is what sets the default: the gateway's own settings section + // now names it, beside the provider profiles the Models page writes. + await expect.poll( + async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), + { timeout: 10_000 }, + ).toContain('api-gateway:') + const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8') + expect(document).toContain(`provider: ${ROUTE}`) + expect(document).toContain(`model: ${MODEL}`) + + // A session created after the switch starts from it... + expect(await currentOf(await createSession('default-model-after'))) + .toEqual({ provider: ROUTE, model: MODEL }) + // ...while the one holding a logged route keeps deriving from its log. + expect(await currentOf(loggedId)) + .toEqual({ provider: 'deepseek-official', model: 'deepseek-v4-flash' }) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) +}) diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 1d9117dc85..7fc31fab7c 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -25,6 +25,7 @@ import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/models-settings', import.meta.url)) const EMPTY_EXPECTED = join(SNAPSHOT_DIR, 'empty.expected.md') const CONFIGURED_EXPECTED = join(SNAPSHOT_DIR, 'configured.expected.md') +const DECLARED_EXPECTED = join(SNAPSHOT_DIR, 'declared.expected.md') const DELETE_EXPECTED = join(SNAPSHOT_DIR, 'delete.expected.md') const MODE = webSnapshotMode() @@ -114,10 +115,47 @@ describe('web e2e: Models settings page configures a dormant provider', () => { expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('declares a route the adapter does not ship, with its own reasoning effort', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-models-declare')) + const dialog = page.getByRole('dialog', { name: '设置' }) + const declare = dialog.getByRole('button', { name: '添加自定义提供方' }) + await expect.poll(async () => declare.isEnabled(), { timeout: 10_000 }).toBe(true) + await declare.click() + await dialog.getByLabel('Provider ID').fill('acme-gateway') + await dialog.getByLabel('显示名称').fill('Acme Gateway') + await dialog.getByLabel('API 地址').fill('https://gateway.acme.example/v1') + // The create card offers the same provider-level effort the editor card + // does for this namespace; a route declared without it would gain the + // control only on reopening. + await dialog.getByLabel('推理强度').selectOption('high') + await dialog.getByRole('button', { name: '添加模型' }).click() + await dialog.getByLabel('模型 ID 1').fill('acme-large') + await dialog.getByRole('button', { name: '创建提供方', exact: true }).click() + + const row = dialog.getByText('Acme Gateway', { exact: true }).first() + await row.waitFor({ timeout: 10_000 }) + const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8') + expect(document).toContain('acme-gateway:') + expect(document).toContain('reasoning: high') + + // The tag follows the adapter's installed catalog: this route is in no + // catalog, while minimax-cn is — even though both now have profiles. + const rowCard = (name: string) => dialog.locator('li').filter({ hasText: name }).first() + await expect.poll(async () => rowCard('Acme Gateway').getByText('自定义').count(), { timeout: 10_000 }).toBe(1) + expect(await rowCard('minimax-cn').getByText('自定义').count()).toBe(0) + + const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(DECLARED_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + it('confirms provider deletion before removing its settings profile', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-models-delete')) const settingsDialog = page.getByRole('dialog', { name: '设置' }) - await settingsDialog.getByRole('button', { name: '删除', exact: true }).click() + // Two rows carry a delete action now that a route is also declared; this + // scenario is about minimax-cn, so it names its own row. + const minimaxRow = settingsDialog.locator('li').filter({ hasText: 'minimax-cn' }).first() + await minimaxRow.getByRole('button', { name: '删除', exact: true }).click() const deleteDialog = page.getByRole('dialog', { name: '删除模型提供方?' }) await deleteDialog.waitFor({ timeout: 10_000 }) const snapshot = await captureStableAria( @@ -129,7 +167,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { await deleteDialog.getByRole('button', { name: '取消', exact: true }).click() expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).toContain('minimax-cn:') - await settingsDialog.getByRole('button', { name: '删除', exact: true }).click() + await minimaxRow.getByRole('button', { name: '删除', exact: true }).click() await page.getByRole('dialog', { name: '删除模型提供方?' }) .getByRole('button', { name: '删除提供方', exact: true }).click() await expect.poll( @@ -147,6 +185,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { }, 60_000) it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { - await assertFixtureInventory(SNAPSHOT_DIR, ['configured.expected.md', 'delete.expected.md', 'empty.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, + ['configured.expected.md', 'declared.expected.md', 'delete.expected.md', 'empty.expected.md']) }) }) diff --git a/apps/web/tests/snapshots/models-settings/declared.expected.md b/apps/web/tests/snapshots/models-settings/declared.expected.md new file mode 100644 index 0000000000..3aa3d64cc9 --- /dev/null +++ b/apps/web/tests/snapshots/models-settings/declared.expected.md @@ -0,0 +1,30 @@ +- dialog "设置": + - navigation: + - text: 设置 + - button "通用设置": + - img + - text: 通用设置 + - button "模型": + - img + - text: 模型 + - button "打开配置文件" + - button "关闭": + - img + - text: 关闭 + - heading "模型" [level=2] + - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - list: + - listitem: + - text: minimax-cn + - button "编辑" + - button "删除" + - listitem: + - text: Acme Gateway 自定义 + - button "编辑" + - button "删除" + - button "添加提供方": + - img + - text: 添加提供方 + - button "添加自定义提供方": + - img + - text: 添加自定义提供方 diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 7d509957ad..b74eacdeb0 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -37,6 +37,7 @@ "tests/details-session-lifecycle.e2e.ts", "tests/settings-chrome.e2e.ts", "tests/models-settings.e2e.ts", + "tests/default-model.e2e.ts", "tests/onboarding-deepseek-config.e2e.ts", "tests/remote-welcome.e2e.ts", "tests/workspace-management.e2e.ts", diff --git a/docs/config-catalog.md b/docs/config-catalog.md index ad27aac3b9..f1c37f97c4 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -578,17 +578,27 @@ Requires: `agents` · `directoryPicker` · `llm` · `sessions` · `subagents` · ```ts config-catalog /** Gateway plugin config: host-level agent routing and Workspace creation root. */ -export interface Config { - /** Default provider route for created/resumed agents. */ - provider: string - /** Default model id. */ - model: string +export interface Config extends DefaultRouteSettings { /** Parent directory for name-created Workspaces; defaults to the Host cwd. */ workspaceRoot?: string } + +/** + * The user-settable slice of the gateway config: the route a session starts + * from when its own log names none. `workspaceRoot` is deliberately not part + * of it — that is a launcher fact, not a preference. + */ +export interface DefaultRouteSettings { + /** Default provider route for created agents. */ + provider: string + /** Default model id. */ + model: string + /** Default reasoning effort; absence preserves the adapter/provider default. */ + reasoningEffort?: string +} ``` -Source: [`packages/host/apiproxy/src/index.ts:33`](../packages/host/apiproxy/src/index.ts) +Source: [`packages/host/apiproxy/src/index.ts:64`](../packages/host/apiproxy/src/index.ts) ## `@deepseek-ai/dsh-host-directory-picker-browse` diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index 5600da9e54..a048f4e43d 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/core.md -core.md: 52e77be89d939eefa2b42ef5586c5798e194a303 -core.zh.md: 5f0134a4c8b3dead49830b41f62e8b7238327cfa +core.md: eb96988abe096455c4f24ac220a6da3f266e690d +core.zh.md: 7334b3d3a5bd088f5467a72d7357f87c4c745487 diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 52e77be89d..eb96988abe 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -313,6 +313,15 @@ interface LlmConfigurableProvider { * object; empty when the whole section is the profile. */ settingsPath: readonly string[] + /** + * Whether the owning adapter knows this route only because configuration + * declared it — a gateway or self-hosted server it ships nothing about. + * Absent means the adapter draws no such distinction; false means it does + * and this route is one of its own. Only the adapter can answer: a stored + * profile is how a user-added route AND a corrected shipped one both look + * from outside. + */ + declared?: boolean } ``` diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index 5f0134a4c8..7334b3d3a5 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -319,6 +319,15 @@ interface LlmConfigurableProvider { * object; empty when the whole section is the profile. */ settingsPath: readonly string[] + /** + * Whether the owning adapter knows this route only because configuration + * declared it — a gateway or self-hosted server it ships nothing about. + * Absent means the adapter draws no such distinction; false means it does + * and this route is one of its own. Only the adapter can answer: a stored + * profile is how a user-added route AND a corrected shipped one both look + * from outside. + */ + declared?: boolean } ``` diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index dc2f8c5967..9559b5e198 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2500,8 +2500,11 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { 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 }, - { provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false }, + { 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, { groups: fixtureModelGroups(), failures: [] }), diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index ae296a91aa..ba7816421b 100644 --- a/packages/client/ui-models/README.i18n.yaml +++ b/packages/client/ui-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-models/README.md -README.md: b55914197e472edec8a8b6d4d3e02036d1697728 -README.zh.md: ca93c3d5a2a85fffb22707f8389f1e979468e2ec +README.md: ea3efd5b0a7ee3599fda74cd9a361222170c473d +README.zh.md: 96290d6e56f36d485ea3e0b661197eda4cc40c09 diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index b55914197e..ea3efd5b0a 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Models settings plugin: the provider configuration page and official-DeepSeek conditional onboarding step. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status. -Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and 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. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), `reasoningEffort` (deepseek) or `reasoning` (pi-ai), and the direct DeepSeek adapter's advisory model catalog. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and a localized confirmation dialog must complete before the page submits that destructive unset. +Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and 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. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), `reasoningEffort` (deepseek) or `reasoning` (pi-ai), and the direct DeepSeek adapter's advisory model catalog. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and a localized confirmation dialog must complete before the page submits that destructive unset. A row is tagged **Custom** when the directory entry says the owning adapter ships nothing under that key. The tag follows that answer alone: having a stored profile does not make a route custom — narrowing a shipped provider's models stores one too — and an adapter that reports nothing leaves its rows untagged rather than being read as shipped. The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface. @@ -16,7 +16,7 @@ A pi-ai profile's `models` list is edited on the card: one row per model showing **Fetch available models** asks `llm.discoverModels` about the endpoint the form **currently shows**, including a base URL edited but not yet saved and a key typed but not yet stored, so adding a provider is one pass instead of save-then-return. The reply opens a picker rather than being written: candidates already configured start unchecked, so adopting a selection never overwrites a capacity the user corrected. A provider that cannot be interrogated is a detour, not a dead end — the adapter's own message appears beside the rows, which stay editable by hand. -**Add a custom provider** declares a route pi-ai does not ship. It is its own card rather than the editor with extra fields, because the route id is being chosen here and the settings address does not exist until it is: one `settings.mutate` sets the whole profile at `providers.`, and the key travels separately through `credentials.set` under the same `_API_KEY` derivation an existing provider uses. What a hand-declared route cannot default gates the create button — a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model — so the failure names the field while the user is still looking at it. Capacities do not gate it: the adapter's fallbacks size a model the endpoint described by id alone, which is what most listings return. The protocol choices are read out of the namespace's own schema rather than a wire field or a constant, so they cannot drift from the ones the adapter accepts. +**Add a custom provider** declares a route pi-ai does not ship. It is its own card rather than the editor with extra fields, because the route id is being chosen here and the settings address does not exist until it is: one `settings.mutate` sets the whole profile at `providers.`, and the key travels separately through `credentials.set` under the same `_API_KEY` derivation an existing provider uses. What a hand-declared route cannot default gates the create button — a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model — so the failure names the field while the user is still looking at it. Capacities do not gate it: the adapter's fallbacks size a model the endpoint described by id alone, which is what most listings return. The protocol choices are read out of the namespace's own schema rather than a wire field or a constant, so they cannot drift from the ones the adapter accepts. The card offers the same provider-level reasoning effort the editor card does for this namespace, from one shared control: both write the same profile field, so a route declared without it would have gained the setting only on being reopened. ## Model Experience diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index ca93c3d5a2..96290d6e56 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -4,7 +4,7 @@ 模型设置插件:提供方配置页和按条件显示的 DeepSeek 官方首次使用引导步骤。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。 -行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可以询问提供方它服务什么。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另有 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai),以及直接 DeepSeek 适配器的建议性模型目录。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),而且必须先在本地化对话框中确认,页面才会提交这次破坏性的 unset。 +行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可以询问提供方它服务什么。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另有 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai),以及直接 DeepSeek 适配器的建议性模型目录。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),而且必须先在本地化对话框中确认,页面才会提交这次破坏性的 unset。当目录条目表明拥有该路由的适配器在这个键下什么都没有时,该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器,其路由保持无标签,不会被当成内置。 前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。 @@ -16,7 +16,7 @@ pi-ai profile 的 `models` 列表就在卡片上编辑:一行一个模型, **获取可用模型**会针对表单**当前显示**的端点调用 `llm.discoverModels`,包括已修改但尚未保存的 API 地址和已键入但尚未存储的密钥,因此新增一个提供方是一趟走完,而不是「先保存再回来」。回复会打开一个选择框而不是直接写入:已配置过的候选默认不勾选,因此采纳一次选择绝不会覆盖用户已更正的容量。无法被询问的提供方只是绕路而非死路——适配器自己的消息会显示在各行旁边,而这些行仍可手工编辑。 -**添加自定义提供方**用来声明 pi-ai 未提供的路由。它是独立的一张卡片而非在编辑器上加字段,因为路由 id 正是在这里被*选定*的,而在选定之前 settings 地址并不存在:一次 `settings.mutate` 在 `providers.` 上设置整个 profile,密钥则经 `credentials.set` 单独传递,使用与既有提供方相同的 `_API_KEY` 派生。手工声明的路由无法默认的东西会门控创建按钮——唯一的 **Provider ID**、端点、协议,以及至少一个由唯一标识的模型——因此失败会在用户仍看着该字段时点名它。容量不参与门控:端点只按 id 描述的模型(这正是多数列表返回的形态)由适配器的回退值定尺寸。协议选项读自该 namespace 自己的 schema,而非某个协议字段或常量,因此它们不会与适配器实际接受的集合发生漂移。 +**添加自定义提供方**用来声明 pi-ai 未提供的路由。它是独立的一张卡片而非在编辑器上加字段,因为路由 id 正是在这里被*选定*的,而在选定之前 settings 地址并不存在:一次 `settings.mutate` 在 `providers.` 上设置整个 profile,密钥则经 `credentials.set` 单独传递,使用与既有提供方相同的 `_API_KEY` 派生。手工声明的路由无法默认的东西会门控创建按钮——唯一的 **Provider ID**、端点、协议,以及至少一个由唯一标识的模型——因此失败会在用户仍看着该字段时点名它。容量不参与门控:端点只按 id 描述的模型(这正是多数列表返回的形态)由适配器的回退值定尺寸。协议选项读自该 namespace 自己的 schema,而非某个协议字段或常量,因此它们不会与适配器实际接受的集合发生漂移。这张卡片提供与编辑器卡片在该 namespace 下相同的提供方级推理等级,两者共用同一个控件:它们写的是同一个 profile 字段,若声明时没有它,这个设置就会等到重新打开编辑时才凭空出现。 ## 模型体验 diff --git a/packages/client/ui-models/src/client/ModelsSection.module.css b/packages/client/ui-models/src/client/ModelsSection.module.css index a4d4d04121..ca99d6d4e9 100644 --- a/packages/client/ui-models/src/client/ModelsSection.module.css +++ b/packages/client/ui-models/src/client/ModelsSection.module.css @@ -72,6 +72,20 @@ color: var(--dsw-alias-label-primary); } +/* Reads as an annotation on the name, not as a second name: caption size and + the secondary label tone, so it never competes with the row's own title. + `rowActions` keeps the `margin-left: auto`, which is what holds the tag + beside the name instead of letting it drift across the row. */ +.rowTag { + flex: none; + padding: 1px 6px; + border: 1px solid var(--dsw-alias-border-l3); + border-radius: 4px; + font-size: 11px; + line-height: 16px; + color: var(--dsw-alias-label-secondary); +} + .rowActions { display: inline-flex; align-items: center; diff --git a/packages/client/ui-models/src/client/ModelsSection.tsx b/packages/client/ui-models/src/client/ModelsSection.tsx index a69d11dd6c..a883bb293b 100644 --- a/packages/client/ui-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-models/src/client/ModelsSection.tsx @@ -205,6 +205,12 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
  • {row.entry.displayName} + {/* Only the adapter can tell a hand-declared route from a + shipped one it also has a stored profile for, so the tag + follows its answer and stays off when it gives none. */} + {row.entry.declared === true + ? {t('customTag')} + : null}
    @@ -178,7 +194,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { value={displayName} placeholder={route.length === 0 ? t('customDisplayName') : route} aria-label={t('customDisplayName')} - disabled={disabled} + disabled={profileDisabled} onChange={(event) => { setDisplayName(event.target.value) }} /> @@ -190,7 +206,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { value={baseURL} placeholder="https://gateway.example/v1" aria-label={t('baseUrl')} - disabled={disabled} + disabled={profileDisabled} onChange={(event) => { setBaseURL(event.target.value) }} /> @@ -200,7 +216,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { className={styles['input']} value={protocol} aria-label={t('customApi')} - disabled={disabled} + disabled={profileDisabled} onChange={(event) => { setProtocol(event.target.value) }} > {protocols.map(choice => )} @@ -226,7 +242,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { value={effort ?? ''} onChange={setEffort} t={t} - disabled={disabled} + disabled={profileDisabled} /> {failure !== undefined ?

    {failure}

    : null} {/* Only the gates with something to say render; the route-id gate has its @@ -251,7 +267,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { submitDisabled={disabled || !ready} submitLabel="create" submitBusyLabel="creating" - onCancel={() => { props.onClose(false) }} + onCancel={() => { props.onClose(committed) }} onSubmit={() => { void create() }} /> diff --git a/packages/client/ui-models/tests/provider-form.spec.tsx b/packages/client/ui-models/tests/provider-form.spec.tsx index 67def367bc..5d359c2476 100644 --- a/packages/client/ui-models/tests/provider-form.spec.tsx +++ b/packages/client/ui-models/tests/provider-form.spec.tsx @@ -650,8 +650,11 @@ describe('provider rows', () => { }) describe('hand-declared providers', () => { - function mountCard(overrides: Partial[0]> = {}) { - const scripted = scriptedFace() + function mountCard( + overrides: Partial[0]> = {}, + wire: Parameters[0] = {}, + ) { + const scripted = scriptedFace(wire) const onClose = vi.fn() render( { expect(firstMutate(second.mutate).ops[0]).not.toHaveProperty('value.reasoning') }) + it('retries only the key after the profile landed, and reports the provider on cancel', async () => { + const set = vi.fn() + .mockResolvedValueOnce(fail('credential store is read-only', 'credential-rejected')) + .mockResolvedValueOnce(ok({})) + const { mutate, onClose } = mountCard({}, { set }) + + fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } }) + fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } }) + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: ' gw-key ' } }) + fireEvent.click(screen.getByRole('button', { name: en.addModel })) + fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } }) + fireEvent.click(screen.getByText(en.create)) + + // The profile landed; only the key failed. The card says so and stays open. + await waitFor(() => { expect(screen.getByText('credential store is read-only')).toBeTruthy() }) + expect(onClose).not.toHaveBeenCalled() + expect(mutate).toHaveBeenCalledTimes(1) + // The key is stored trimmed, matching the editor. + expect(set).toHaveBeenNthCalledWith(1, { ref: 'ACME_API_KEY', value: 'gw-key' }) + + // The provider exists now, so the fields describing it are settled and + // only the key can still be corrected. + expect(screen.getByLabelText(en.customRoute).disabled).toBe(true) + expect(screen.getByLabelText(en.baseUrl).disabled).toBe(true) + expect(screen.getByLabelText(en.keyInput).disabled).toBe(false) + + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'gw-key-2' } }) + fireEvent.click(screen.getByText(en.create)) + await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) }) + // Re-running the profile write would carry the revision this card's own + // first write superseded, so the Host would answer settings-conflict and + // the key could never be stored from here at all. + expect(mutate).toHaveBeenCalledTimes(1) + expect(set).toHaveBeenNthCalledWith(2, { ref: 'ACME_API_KEY', value: 'gw-key-2' }) + }) + + it('reports the created provider when cancelled after its profile landed', async () => { + const set = vi.fn().mockResolvedValue(fail('nope', 'credential-rejected')) + const { onClose } = mountCard({}, { set }) + + fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } }) + fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } }) + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'gw-key' } }) + fireEvent.click(screen.getByRole('button', { name: en.addModel })) + fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } }) + fireEvent.click(screen.getByText(en.create)) + await waitFor(() => { expect(screen.getByText('nope')).toBeTruthy() }) + + // Walking away leaves a real provider behind; reporting no change would + // leave the page without the row it now has. + fireEvent.click(screen.getByText(en.cancel)) + expect(onClose).toHaveBeenCalledWith(true) + }) + it('names the blocked gate under the form, and nothing once it is satisfied', () => { mountCard() fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } }) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index a5e9fd2aef..cc4fff8e24 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: 38f18995f2982db2c5a48971d7d044448e5adc8c -README.zh.md: c444ed6b7485b6ddca059c5edf7f30828bd96ab7 +README.md: 9e01423a36803477cb07d944e058fc388b5e72fd +README.zh.md: d4df79d7d8850c466f1ccc4c53097a15739013ea diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 38f18995f2..9e01423a36 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -10,7 +10,9 @@ The API gateway every client shape shares: the TS contract (`src/api/`, zero Nod A session resolves its route from three tiers, re-read on every access rather than seeded once: a selection made in this process, else the session's own latest logged `request/header`, else this default. Re-reading is what makes both directions hold — a session that has run a turn derives its route from its log forever after, so changing the default never retargets it, while a session still blank (New Session reuses one rather than minting another) starts from a default saved after it was created. -`session.selectModel` records an accepted switch as the new default, which is how the default is chosen in practice: there is no separate gesture. The write replaces the section wholesale rather than merging, because switching to a model with no reasoning effort has to clear a stored one; a storage failure is logged without undoing the switch, which already applies to its own session. A deployment with no settings provider keeps the composition entry and a switch stays process-local. +`session.selectModel` records an accepted switch as the new default, which is how the default is chosen in practice: there is no separate gesture. What it stores is the RESOLVED target, so an adapter-materialized default effort is pinned as the user saw it and a later adapter-default change does not silently move stored defaults. The write replaces the section wholesale rather than merging, because switching to a model with no reasoning effort has to clear a stored one; a storage failure is logged without undoing the switch, which already applies to its own session. A deployment with no settings provider keeps the composition entry and a switch stays process-local. + +The section's `reasoningEffort` has no counterpart in the plugin config, deliberately: the seam merges the user layer over the composition entry per field, so an absent key cannot override a present one and a composition-set effort would survive every later switch to a model without one. A deployment default for effort belongs on the adapter profile, which resolves per model. The stored route is not validated against the registry, in either direction. A default naming a route the Models page has since removed still reaches `session.models` as the session's `current` — matching no advertised group, which is precisely what makes a selector prompt for a replacement instead of naming a model the deployment cannot reach. Repairing it silently would also break the deliberate converse: an adapter may serve a model its catalog does not advertise. @@ -30,7 +32,7 @@ Session titles ride the generic projection pair like every other domain — the `session.fork` maps an optional event anchor to the first `turn/end` at or after it, letting a message action include that message's whole turn. An omitted or past-end anchor selects the last completed turn; an in-log anchor whose turn remains open returns `fork-unavailable` rather than clipping backward. The published child inherits the source's seeded history, cwd, latest logged provider/model/reasoning target, and lineage before joining the source Workspace. If Workspace attachment fails, `workspace-attach-failed` carries the already-published child id so clients can reconcile it. The [SessionStore fork decision](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) owns the boundary rationale. -Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target separately from provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. The current target may be absent from the groups and is never injected as a synthetic row; clients can prompt for a replacement without turning the directory into a routing whitelist. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`. +Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target separately from provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. The current target may be absent from the groups and is never injected as a synthetic row; clients can prompt for a replacement without turning the directory into a routing whitelist. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`. `session.models` additionally reports `routable`: whether an adapter currently serves the current target's route, which is deliberately NOT derivable from the groups — a route serving a model it stopped advertising is absent from them yet perfectly usable, while a route whose adapter is gone can serve nothing. `session.prompt` refuses on that same fact with `model-unavailable` rather than spending the pre-step path to fail inside an adapter; a client that disables its composer is an affordance, and this method stays callable regardless. Pending queued input is a live control-plane contract, not conversation history. The gateway derives the complete `next-turn` queue from durable `agent/inbox/spliced` mutations and broadcasts authoritative `session/queue` snapshots after each change and on reconnect; pending `next-step` steering stays outside this Web projection. Within `next-step`, user-origin messages carry the `steering` placement while injected context (approval notices, task completion, attached snapshots) carries `context` and is not surfaced until claimed. The message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications remain available to lifecycle observers but do not build the queue view. `session.updateQueue` addresses one `MessageId`; edit and remove mutate the attached Agent through `Inbox.splice()`. A claim's pure deletion splice wins races before pre-step admission, so a later operation returns `queue-item-not-found`. `session.cancel` aborts only the active turn and preserves pending inbox work; after cancellation reaches quiescence and the closing turn flushes, AgentLoop claims the next waking message in FIFO order, and the browser never resends or promotes it. Queue operations never resume a cold session, and the client never infers retirement from turn or status events. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index c444ed6b74..d4df79d7d8 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -10,7 +10,9 @@ 会话按三级解析自己的路由,且每次读取都重新解析,而不是只在创建时种一次:本进程内的显式选择,其次是该会话自己最新记录的 `request/header`,最后才是这个默认值。重新解析正是让两个方向都成立的原因——已经跑过一轮的会话此后永远从自己的日志推导路由,改默认值不会重定向它;而仍然空白的会话(新建会话会复用一个,而不是再开一个)则会用上它创建之后才保存的默认值。 -`session.selectModel` 会把被接受的切换记录为新的默认值,实践中默认值就是这样选定的,没有另一个单独的手势。写入是整段替换而非合并,因为切到一个不支持推理的模型必须清掉已存的等级;存储失败只记日志,不会撤销这次切换——它对自己所在的会话已经生效。没有设置提供方的部署保留组合条目,切换只停留在进程内。 +`session.selectModel` 会把被接受的切换记录为新的默认值,实践中默认值就是这样选定的,没有另一个单独的手势。它存下来的是**解析后**的目标,因此适配器实体化出来的默认推理等级会按用户当时看到的样子钉住,日后适配器改了自己的默认值也不会悄悄移动已存的默认路由。写入是整段替换而非合并,因为切到一个不支持推理的模型必须清掉已存的等级;存储失败只记日志,不会撤销这次切换——它对自己所在的会话已经生效。没有设置提供方的部署保留组合条目,切换只停留在进程内。 + +设置段里的 `reasoningEffort` 在插件配置中刻意没有对应字段:seam 是按字段把用户层合并到组合条目之上的,缺席的键覆盖不了存在的键,因此组合层设的推理等级会在此后每一次切到不支持推理的模型时继续存活。推理等级的部署级默认值属于适配器 profile,那里是按模型解析的。 存下来的路由不做注册表校验,两个方向都不做。默认值指向一个已在模型页删除的路由时,它照样作为会话的 `current` 送到 `session.models`——匹配不到任何已公布的分组,而这恰恰是让选择器提示重新选择、而不是显示一个部署根本够不着的模型的原因。静默修复它还会破坏刻意保留的反面情形:适配器可以服务一个自己目录未公布的模型。 @@ -30,7 +32,7 @@ `session.fork` 将可选事件锚点映射到该锚点处或其后的首个 `turn/end`,使消息操作可包含该消息所在的完整轮次。锚点省略或超过末尾时,选择最后一个已完成轮次;若锚点已在日志中,而其所在轮次仍开放,则返回 `fork-unavailable`,不会向较早位置裁剪。发布后的子会话会先继承源会话的种子历史、cwd、日志中最新的提供方/模型/推理(reasoning)目标及谱系,再加入源 Workspace。如果附加到 Workspace 失败,`workspace-attach-failed` 会携带已发布的子会话 id,供客户端对账。[SessionStore fork 决策](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md)给出边界设计的理由。 -会话模型路由属于会话领域契约。`session.models` 将选中的提供方/模型/推理目标,与按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录分开返回。当前目标可能不在这些分组中,也绝不会作为合成行注入;客户端可以提示用户选择替代目标,而无需把目录变成路由白名单。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`。 +会话模型路由属于会话领域契约。`session.models` 将选中的提供方/模型/推理目标,与按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录分开返回。当前目标可能不在这些分组中,也绝不会作为合成行注入;客户端可以提示用户选择替代目标,而无需把目录变成路由白名单。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`。`session.models` 还会报告 `routable`:当前目标的路由是否有适配器在服务。这一点刻意不由分组推导——一条仍在服务、只是不再公布该模型的路由不在分组里,却完全可用;而适配器已经消失的路由什么都服务不了。`session.prompt` 依据同一个事实以 `model-unavailable` 拒绝,而不是把整条 pre-step 路径走完再在适配器内部失败;客户端禁用输入框只是提示性设计,这个方法始终可被调用。 待处理的 queued 输入属于实时控制平面契约,而非对话历史。网关根据持久 `agent/inbox/spliced` 变更派生完整的 `next-turn` 队列,并在每次变更后及重连时广播权威 `session/queue` 快照;待处理的 `next-step` steering(中途引导)不进入此 Web 投影。在 `next-step` 内,用户来源的消息携带 `steering` placement,而注入上下文(审批通知、任务完成、附加快照)携带 `context`,领取前不对外呈现。面向单条消息的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知仍供生命周期观察方使用,但不用于构建队列视图。`session.updateQueue` 通过 `MessageId` 寻址单个项;编辑和移除经已挂载 Agent 的 `Inbox.splice()` 修改队列。claim 的纯删除 splice 会在 pre-step 准入前赢得竞态,因此之后的操作返回 `queue-item-not-found`。`session.cancel` 仅中止活动轮次并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后,AgentLoop 按 FIFO 顺序认领下一条可唤醒消息,浏览器绝不重发或提升它。队列操作绝不恢复冷会话,客户端也绝不根据轮次或状态事件推断某项已退出队列。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index aaa2d68a44..2a54e2c113 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -74,6 +74,14 @@ import { openNativePath, openNativeTextFile } from './native-path-opener.ts' /** Page size when history is called without maxMessages. */ const DEFAULT_MAX_MESSAGES = 50 +/** + * The settings namespace carrying the user's default route. Named for the + * gateway rather than for the package, because this key is what a person reads + * and writes in `settings.yaml`; the row id in a composition happens to match + * but does not determine it. + */ +export const API_GATEWAY_SETTINGS_NAMESPACE = settingsNamespace('api-gateway') + /** Non-model settings namespaces intentionally served to the Web client. */ const WEB_SETTINGS_NAMESPACES = ['permission'] as const @@ -337,9 +345,11 @@ export interface ApiProxyDefaults { */ defaultTarget: () => AgentLlmTarget /** - * Record a selection as the new default. Absent when the deployment stores - * no user settings, in which case a switch stays process-local. A rejection - * is reported and swallowed: the switch already applies to its own session, + * Record a selection as the new default. Either absent, or a closure that + * may itself decline — the gateway plugin always passes one, and it no-ops + * when the deployment mounts no settings provider or when the write races + * service teardown. A switch then stays process-local. A rejection is + * reported and swallowed: the switch already applies to its own session, * and undoing it because storage failed would be the worse outcome. */ persistDefaultTarget?: (target: AgentLlmTarget) => Promise @@ -1330,6 +1340,19 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } } + /** + * Whether an adapter currently serves this route, and therefore whether a + * session pointed at it can start a turn. Catalog membership cannot answer + * it: an adapter may serve a model its own catalog stopped advertising, so + * a route missing from the groups is not the same as one nothing serves. + * A composition with no llm registry at all cannot judge and says yes — + * the dispatch it would have refused fails on its own terms. + */ + function routeServed(provider: string): boolean { + const llm = ctx.get('llm') + return llm === undefined || llm.listProviders().some(entry => entry.id === provider) + } + /** 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-local) in its composition', details: {} } @@ -1700,7 +1723,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro if ('error' in found) return err(request, found.error) const current = targetFor(found.agent).current const { groups, failures } = await buildModelCatalog(ctx) - return ok(request, { current: { ...current }, groups, failures }) + const routable = routeServed(current.provider) + return ok(request, { current: { ...current }, routable, groups, failures }) }, async selectModel(request) { @@ -1868,6 +1892,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const found = await agentFor(sessionId) if ('error' in found) return err(request, found.error) const agent = found.agent + // A route no adapter serves cannot start a turn, and letting it try + // spends the whole pre-step path to fail inside the adapter with a + // message about registration. Refusing here names the model the + // session is pointed at while the draft is still in the composer. + // This is the enforcement boundary: a client that disables its input + // is an affordance, and this method stays callable regardless. + const target = targetFor(agent).current + if (!routeServed(target.provider)) { + return err(request, { + code: 'model-unavailable', + message: `no adapter serves provider "${target.provider}"; select a model for this session`, + details: { provider: target.provider, model: target.model }, + }) + } // The rpcId rides MessageSource into user/message (merge declaration in api/sessions.ts; provisional correlation). const source: MessageSource = { kind: 'user', rpcId: request.rpcId } try { @@ -2758,8 +2796,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro queue.push(frame({ type: 'host/settings-changed', ns: name })) // A provider's own settings carry its model catalog and endpoint, // so a change there invalidates the model list even when the route - // set is untouched — `llm/adapters-updated` alone misses it. - if (modelProviderNamespaces().has(name)) queue.push(frame({ type: 'host/models-changed' })) + // set is untouched — `llm/adapters-updated` alone misses it. The + // gateway's own section is the other such source: it names the + // route every session with no logged one resolves to, so an + // externally edited default (another tab, a hand-edited + // settings.yaml) has to reach an open selector too. + if (modelProviderNamespaces().has(name) || name === String(API_GATEWAY_SETTINGS_NAMESPACE)) { + queue.push(frame({ type: 'host/models-changed' })) + } }), ctx.on('credentials/updated', (ref) => { queue.push(frame({ type: 'host/credentials-changed', ref: String(ref) })) diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index 9f9c4329e6..80e64fb12a 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -225,6 +225,7 @@ export const sessionModelsRequestSchema = z.object({ /** session.models response value. */ export const sessionModelsValueSchema = z.object({ current: modelTargetSchema, + routable: z.boolean(), groups: z.array(modelProviderGroupSchema), failures: z.array(modelCatalogFailureSchema), }) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 18315eef19..2e795928ec 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -117,6 +117,15 @@ export interface ModelCatalogFailure { export interface SessionModels { /** Target selected for the session's next assembled step. */ current: ModelTarget + /** + * Whether an adapter currently serves `current.provider`, and therefore + * whether this session can start a turn at all. Deliberately NOT derivable + * from `groups`: catalog membership is advisory, so a route serving a model + * it stopped advertising is absent from the groups yet perfectly usable, + * while a route whose adapter is gone can serve nothing. A surface that + * blocks input must read this rather than the groups. + */ + routable: boolean /** Successfully loaded provider groups. */ groups: ModelProviderGroup[] /** Provider-local failures; successful groups remain usable. */ diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index cb2f88f436..1a6e0be281 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -19,16 +19,16 @@ import { Context, Service } from 'cordis' import z from 'schemastery' import type { AgentLlmTarget } from '@deepseek-ai/dsh-agent' import { ReasoningEffortId } from '@deepseek-ai/dsh-llm' -import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' +import { installSettingsSection } from '@deepseek-ai/dsh-settings' import type { ApiProxy } from './api/index.ts' -import { createApiProxy } from './api-proxy.ts' +import { API_GATEWAY_SETTINGS_NAMESPACE, createApiProxy } from './api-proxy.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 { API_GATEWAY_SETTINGS_NAMESPACE, createApiProxy } from './api-proxy.ts' export type { ApiProxyDefaults } from './api-proxy.ts' declare module 'cordis' { @@ -39,17 +39,9 @@ declare module 'cordis' { } /** - * The settings namespace carrying the user's default route. Named for the - * gateway rather than for the package, because this key is what a person reads - * and writes in `settings.yaml`; the row id in a composition happens to match - * but does not determine it. - */ -export const API_GATEWAY_SETTINGS_NAMESPACE = settingsNamespace('api-gateway') - -/** - * The user-settable slice of the gateway config: the route a session starts - * from when its own log names none. `workspaceRoot` is deliberately not part - * of it — that is a launcher fact, not a preference. + * The `api-gateway` settings section: the route a session starts from when its + * own log names none. `workspaceRoot` is deliberately not part of it — that is + * a launcher fact, not a preference. */ export interface DefaultRouteSettings { /** Default provider route for created agents. */ @@ -60,29 +52,36 @@ export interface DefaultRouteSettings { reasoningEffort?: string } -/** Gateway plugin config: host-level agent routing and Workspace creation root. */ -export interface Config extends DefaultRouteSettings { +/** + * Gateway plugin config: host-level agent routing and Workspace creation root. + * + * `reasoningEffort` is deliberately absent, so the section carries one field + * the composition cannot. The seam resolves a section by MERGING the user + * layer over the composition entry per field, and an absent key cannot + * override a present one — so a composition-set effort would survive every + * later switch to a model that has none, and strand it for the next session + * to fail on. Effort is a per-model fact anyway: a deployment default belongs + * on the adapter profile (`llm-pi-ai`'s `reasoning`, `llm-deepseek`'s own), + * which resolves per model rather than per gateway. + */ +export interface Config { + /** Default provider route for created agents. */ + provider: string + /** Default model id. */ + model: string /** Parent directory for name-created Workspaces; defaults to the Host cwd. */ workspaceRoot?: string } -/** The config fields the settings section carries; the rest stay launcher-owned. */ -const DEFAULT_ROUTE_FIELDS = ['provider', 'model', 'reasoningEffort'] as const - /** - * The settings section's schema, picked out of the plugin config rather than - * restated. The config stays a plain literal because the configuration-catalog - * generator reads it statically; picking from it is what keeps the section a - * subset of it as both evolve. - * @param config - the plugin config schema to pick from. - * @returns the section schema over {@link DEFAULT_ROUTE_FIELDS}. + * Schema of the `api-gateway` section, exported because it IS that section's + * contract — the shape anything reading or writing `settings.yaml` addresses. */ -function defaultRouteSchema(config: z): z { - const fields = Object.fromEntries( - DEFAULT_ROUTE_FIELDS.map(field => [field, config.dict?.[field]]), - ) - return z.object(fields) as z -} +export const DEFAULT_ROUTE_SCHEMA: z = z.object({ + provider: z.string().required(), + model: z.string().required(), + reasoningEffort: z.string(), +}) /** Project the stored/composed section onto the agent-facing target shape. */ function routeTarget(settings: DefaultRouteSettings): AgentLlmTarget { @@ -109,7 +108,6 @@ export class ApiProxyService extends Service implements ApiProxy { static Config: z = z.object({ provider: z.string().required(), model: z.string().required(), - reasoningEffort: z.string(), workspaceRoot: z.string(), }) @@ -132,13 +130,9 @@ export class ApiProxyService extends Service implements ApiProxy { // The composition entry is the shipped default; the settings section // layers the user's own choice over it, and a deployment without a // settings provider simply keeps the entry. - const entry: DefaultRouteSettings = { - provider: config.provider, - model: config.model, - ...config.reasoningEffort === undefined ? {} : { reasoningEffort: config.reasoningEffort }, - } + const entry: DefaultRouteSettings = { provider: config.provider, model: config.model } let route: () => DefaultRouteSettings = () => entry - installSettingsSection(ctx, API_GATEWAY_SETTINGS_NAMESPACE, defaultRouteSchema(ApiProxyService.Config), entry, { + installSettingsSection(ctx, API_GATEWAY_SETTINGS_NAMESPACE, DEFAULT_ROUTE_SCHEMA, entry, { setSource: (current) => { route = current }, @@ -150,8 +144,10 @@ export class ApiProxyService extends Service implements ApiProxy { defaultTarget: () => routeTarget(route()), // Wholesale, never a merge: switching to a model with no reasoning // effort must clear a stored one, and a merged patch would strand it - // for the next session to fail on. The section holds no secrets, so - // there is nothing a replace can collaterally drop. + // for the next session to fail on. This clears it because the entry + // below the user layer carries no effort to re-inherit — the reason + // `Config` deliberately has no such field. The section holds no + // secrets, so there is nothing a replace can collaterally drop. persistDefaultTarget: async (target) => { await ctx.get('settings')?.replace(API_GATEWAY_SETTINGS_NAMESPACE, target) }, diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts index c13a66eaec..86a77f4af2 100644 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts @@ -22,7 +22,7 @@ import type { CredentialInfo, CredentialRef, ResolvedCredential } from '@deepsee import type { HostFrame } from '../src/api/index.ts' import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts' import { RpcId } from '../src/api/rpc.ts' -import { createApiProxy } from '../src/api-proxy.ts' +import { API_GATEWAY_SETTINGS_NAMESPACE, createApiProxy } from '../src/api-proxy.ts' const DEFAULTS = { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' } @@ -398,6 +398,25 @@ describe('settings domain', () => { expect(frames).toEqual([{ type: 'host/settings-changed', ns: 'permission' }]) }) + it('invalidates the model catalog when the gateway default route changes', async () => { + const ctx = await harness() + const route = ctx.settings.register(API_GATEWAY_SETTINGS_NAMESPACE, z.object({ + provider: z.string().required(), + model: z.string().required(), + }), { base: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } }) + const api = createApiProxy(ctx, DEFAULTS) + // The gateway's own section names the route every session with no logged + // one resolves to, so an externally edited default — another tab, a + // hand-edited settings.yaml — has to reach an open selector as well. + const frames = await collectHost(api, ['host/settings-changed', 'host/models-changed'], 2, async () => { + await route.replace({ provider: 'deepseek-official', model: 'deepseek-reasoner' }) + }) + expect(frames).toEqual([ + { type: 'host/settings-changed', ns: 'api-gateway' }, + { type: 'host/models-changed' }, + ]) + }) + it('maps a stale expectedRevision to settings-conflict carrying both revisions', async () => { const ctx = await harness() ctx.settings.register(NS, AdapterConfig) diff --git a/packages/host/apiproxy/tests/api-proxy-default-route.spec.ts b/packages/host/apiproxy/tests/api-proxy-default-route.spec.ts new file mode 100644 index 0000000000..996cea5da2 --- /dev/null +++ b/packages/host/apiproxy/tests/api-proxy-default-route.spec.ts @@ -0,0 +1,108 @@ +/** + * The `api-gateway` settings section over a REAL settings provider: the + * composition entry as the base layer, the wholesale replace the gateway + * persists with, and the fallback when the provider detaches. The other model + * specs drive hand-rolled `defaultTarget`/`persistDefaultTarget` closures, so + * this is the only place the layering itself is exercised. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { Settings, installSettingsSection } from '@deepseek-ai/dsh-settings' +import type { SettingsNamespace } from '@deepseek-ai/dsh-settings' +import { API_GATEWAY_SETTINGS_NAMESPACE, DEFAULT_ROUTE_SCHEMA } from '../src/index.ts' +import type { DefaultRouteSettings } from '../src/index.ts' + +/** The smallest real provider: one in-memory document, always writable. */ +class MemorySettings extends Settings { + doc: Record = {} + + get writable(): boolean { + return true + } + + protected load(): Promise> { + return Promise.resolve(structuredClone(this.doc)) + } + + protected persist(ns: SettingsNamespace, section: Record): Promise { + this.doc = { ...this.doc, [ns]: structuredClone(section) } + return Promise.resolve() + } +} + +/** Mount the gateway's own section wiring over a live provider. */ +async function boot(entry: DefaultRouteSettings) { + const ctx = new Context() + const fiber = ctx.plugin(MemorySettings) + await fiber.await() + let route: () => DefaultRouteSettings = () => entry + const consumer = ctx.plugin(function section(child: Context) { + installSettingsSection(child, API_GATEWAY_SETTINGS_NAMESPACE, DEFAULT_ROUTE_SCHEMA, entry, { + setSource: (current) => { route = current }, + onChange: () => {}, + }) + }) + await consumer.await() + const settings = ctx.get('settings') + if (settings === undefined) throw new Error('settings provider did not mount') + return { ctx, fiber, consumer, settings, read: () => route() } +} + +describe('the api-gateway default-route section', () => { + it('resolves the composition entry until the user layer overrides it', async () => { + const bench = await boot({ provider: 'deepseek-official', model: 'deepseek-v4-flash' }) + expect(bench.read()).toEqual({ provider: 'deepseek-official', model: 'deepseek-v4-flash' }) + + await bench.settings.replace(API_GATEWAY_SETTINGS_NAMESPACE, { + provider: 'acme-gateway', model: 'acme-large', reasoningEffort: 'high', + }) + expect(bench.read()).toEqual({ + provider: 'acme-gateway', model: 'acme-large', reasoningEffort: 'high', + }) + await bench.ctx.fiber.dispose() + }) + + it('clears a stored effort when the next switch has none', async () => { + const bench = await boot({ provider: 'deepseek-official', model: 'deepseek-v4-flash' }) + await bench.settings.replace(API_GATEWAY_SETTINGS_NAMESPACE, { + provider: 'acme-gateway', model: 'acme-large', reasoningEffort: 'high', + }) + expect(bench.read().reasoningEffort).toBe('high') + + // The whole reason the gateway persists with `replace` rather than a merge + // patch — and the reason `Config` carries no effort for the base layer to + // re-inherit here. A stranded effort would fail the next session's first + // request against a model that does not support it. + await bench.settings.replace(API_GATEWAY_SETTINGS_NAMESPACE, { + provider: 'acme-gateway', model: 'acme-plain', + }) + expect(bench.read()).toEqual({ provider: 'acme-gateway', model: 'acme-plain' }) + await bench.ctx.fiber.dispose() + }) + + it('layers a hand-written partial section over the entry', async () => { + const bench = await boot({ provider: 'deepseek-official', model: 'deepseek-v4-flash' }) + // Someone editing settings.yaml by hand may name only the model. The + // entry supplies the provider, which is what makes this legal — and is + // exactly why an effort in the entry could never be cleared, so there + // is none to inherit. + await bench.settings.replace(API_GATEWAY_SETTINGS_NAMESPACE, { model: 'deepseek-reasoner' }) + expect(bench.read()).toEqual({ provider: 'deepseek-official', model: 'deepseek-reasoner' }) + await bench.ctx.fiber.dispose() + }) + + it('falls back to the composition entry when the provider detaches', async () => { + const bench = await boot({ provider: 'deepseek-official', model: 'deepseek-v4-flash' }) + await bench.settings.replace(API_GATEWAY_SETTINGS_NAMESPACE, { + provider: 'acme-gateway', model: 'acme-large', + }) + expect(bench.read().provider).toBe('acme-gateway') + + // A deployment that loses its settings provider keeps serving the route it + // was composed with rather than the one it can no longer read. + await bench.fiber.dispose() + expect(bench.read()).toEqual({ provider: 'deepseek-official', model: 'deepseek-v4-flash' }) + await bench.ctx.fiber.dispose() + }) +}) diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index 7a9f2b2f86..818260504c 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -303,6 +303,37 @@ describe('Web session model selection', () => { await ctx.fiber.dispose() }) + it('refuses a prompt no adapter can route, and reports it on the directory', async () => { + const { ctx, sessionId } = await harness() + const api = createApiProxy(ctx, { + defaultTarget: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }), + cwd: '/tmp', + workspaceRoot: '/tmp', + }) + + // The client disabling its input is an affordance; this method stays + // callable, so the refusal has to live here. + const refused = await api.sessions.prompt(request({ + sessionId, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'hi' }], + })) + expect(refused.result).toMatchObject({ + ok: false, + error: { code: 'model-unavailable', details: { provider: 'deleted-gateway', model: 'deleted-model' } }, + }) + expect(expectValue(await api.sessions.models(request({ sessionId }))).routable).toBe(false) + + // An advisory-unlisted model on a live route is NOT this: the route + // serves it, so the prompt goes through and nothing blocks. + expectValue(await api.sessions.selectModel(request({ + sessionId, provider: 'deepseek-official', model: 'unlisted-but-served', + }))) + const catalog = expectValue(await api.sessions.models(request({ sessionId }))) + expect(catalog.routable).toBe(true) + expect(catalog.groups.flatMap(group => group.models.map(model => model.id))) + .not.toContain('unlisted-but-served') + await ctx.fiber.dispose() + }) + it('serves a session and its catalog when the stored default names a route that is gone', async () => { const { ctx, sessionId } = await harness() const api = createApiProxy(ctx, { diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 490e0ad7f1..ebd56ee551 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -45,6 +45,7 @@ function scriptedApi(overrides: { }), models: r => ok(r, { current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + routable: true, groups: [], failures: [], }), diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index bcccfdd52e..22e1650f5b 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -64,6 +64,7 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra ok: true, value: { current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + routable: true, groups: [], failures: [], }, diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 040fe56ff5..28f9138502 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -197,6 +197,7 @@ describe('sessions domain schemas', () => { expect(sessionModelsRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1') expect(sessionModelsValueSchema.parse({ current: { provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: 'max' }, + routable: true, groups: [{ id: 'deepseek-official', name: 'DeepSeek', @@ -274,8 +275,10 @@ describe('sessions domain schemas', () => { describe('host domain schemas', () => { it('validates describe request/value', () => { expect(hostDescribeRequestSchema.parse({})).toEqual({}) - const value = hostDescribeValueSchema.parse({ version: '1', cwd: '/x', defaultTarget: () => ({ provider: 'p', model: 'm' }), attachedSessions: 2 }) - expect(value.attachedSessions).toBe(2) + const value = hostDescribeValueSchema.parse({ + version: '1', cwd: '/x', provider: 'p', model: 'm', attachedSessions: 2, + }) + expect(value).toMatchObject({ provider: 'p', model: 'm', attachedSessions: 2 }) expect(hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0 }).provider).toBeUndefined() }) From 8e57dd1dac85be4430ff6a214f8951d875dfbbcd Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 7 Aug 2026 16:15:10 +0800 Subject: [PATCH 048/104] fix(web): render Skill icon at 14px --- .../feature/2026-08-06-web-skill-tool-row.i18n.yaml | 4 ++-- .../implemented/feature/2026-08-06-web-skill-tool-row.md | 2 +- .../implemented/feature/2026-08-06-web-skill-tool-row.zh.md | 2 +- packages/client/ui-skill/README.i18n.yaml | 4 ++-- packages/client/ui-skill/README.md | 2 +- packages/client/ui-skill/README.zh.md | 2 +- packages/client/ui-skill/src/client/SkillRow.tsx | 2 +- packages/client/ui-skill/tests/skill-row.spec.tsx | 2 +- 8 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.i18n.yaml index a9ee64e640..3be2c476e5 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-web-skill-tool-row.md -2026-08-06-web-skill-tool-row.md: 6583062f38b0e9cff059fa4477313ff6a5bdd2aa -2026-08-06-web-skill-tool-row.zh.md: 3d5c4b712896c2cf41df3ec913c597f7f791486c +2026-08-06-web-skill-tool-row.md: fcf5c3b5b61c94b0823fe54624c3dc906c520348 +2026-08-06-web-skill-tool-row.zh.md: bef36df44d97af3993c9760a6b6d3add7b7c932c diff --git a/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.md b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.md index 6583062f38..fcf5c3b5b6 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.md +++ b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.md @@ -12,7 +12,7 @@ The Web transcript renders `skill` calls through the generic fallback row, so a `ui-skill` registers a component under the existing `conversation.chat.toolview` keyed slot with key `skill`. The component owns its row chrome from the public `ToolRowProps` contract, matching the independent registrant posture used by the Bash sample instead of importing conversation-private components. -The collapsed row uses a 16-pixel document-and-sparkle glyph and the Bash row's neutral hierarchy: tertiary glyph, secondary `Skill` title, caption separator, and tertiary skill name. Running, failed, and interrupted calls retain the transcript's shimmer, error dot and first-line summary, and warning dot semantics. A settled call expands through the whole summary row into a 260-pixel bounded `Instructions` card containing the exact durable result text; the existing trajectory `Inspect` handoff remains available below the card. +The collapsed row uses a 14-pixel document-and-sparkle glyph and the Bash row's neutral hierarchy: tertiary glyph, secondary `Skill` title, caption separator, and tertiary skill name. Running, failed, and interrupted calls retain the transcript's shimmer, error dot and first-line summary, and warning dot semantics. A settled call expands through the whole summary row into a 260-pixel bounded `Instructions` card containing the exact durable result text; the existing trajectory `Inspect` handoff remains available below the card. The row derives every visible value from a paired call/result slice in the current runtime window. It reads the skill name from the recorded `name` argument and the instructions from durable result content, and never joins the current skill catalog for descriptions or provider metadata. If pagination leaves the call outside the window, the result has no tool identity and remains on the generic fallback rather than extending the history wire contract. The existing ACP `skill-load` recording is seeded through the real Web persistence and composition path for a keyless interaction and accessibility snapshot. diff --git a/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.zh.md b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.zh.md index 3d5c4b7128..bef36df44d 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.zh.md +++ b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.zh.md @@ -12,7 +12,7 @@ Web transcript(文本记录)通过通用后备行渲染 `skill` 调用,使 `ui-skill` 在现有的 `conversation.chat.toolview` 键控 slot 下注册 key 为 `skill` 的组件。该组件基于公开的 `ToolRowProps` 契约自行实现行 chrome,沿用 Bash 示例的独立注册方姿态,而不导入 conversation 私有组件。 -收起的行使用 16 像素的文档与闪光组合图标,并沿用 Bash 行的中性色层级:图标采用三级色,`Skill` 标题采用二级色,分隔符采用 caption 色,skill 名称采用三级色。运行、失败和中断调用分别沿用 transcript 的扫光、错误状态点加首行摘要,以及警告状态点语义。已结算调用可以通过整个摘要行展开一个高度上限为 260 像素的 `Instructions` 卡片,其中原样呈现持久化结果文本;用于跳转至 trajectory 的现有 `Inspect` 入口仍保留在卡片下方。 +收起的行使用 14 像素的文档与闪光组合图标,并沿用 Bash 行的中性色层级:图标采用三级色,`Skill` 标题采用二级色,分隔符采用 caption 色,skill 名称采用三级色。运行、失败和中断调用分别沿用 transcript 的扫光、错误状态点加首行摘要,以及警告状态点语义。已结算调用可以通过整个摘要行展开一个高度上限为 260 像素的 `Instructions` 卡片,其中原样呈现持久化结果文本;用于跳转至 trajectory 的现有 `Inspect` 入口仍保留在卡片下方。 该行的所有可见值均派生自当前 runtime 窗口中已配对的调用/结果片段。skill 名称来自已记录的 `name` 参数,指令来自持久化的结果内容;该行绝不关联当前 skill 目录来读取描述或提供方元数据。如果分页将调用留在窗口外,结果便没有工具身份,并继续使用通用后备路径,而不是扩展 history 协议契约。现有的 ACP(Agent Client Protocol)`skill-load` 记录经由真实的 Web 持久化与组合路径写入,用于无需密钥的交互和无障碍快照。 diff --git a/packages/client/ui-skill/README.i18n.yaml b/packages/client/ui-skill/README.i18n.yaml index 57a1ff1676..ca4bc68ebf 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: a9506fe563b94fb4d1f9afd882216e023b0c2d13 -README.zh.md: 6af5d3eb8820dacc2ab569be8b830481dd45fb9a +README.md: f70bd2780f255cd8e0c64acb3da3863e10c4fa9d +README.zh.md: 6eb6cbd3ae196a540e161a3a23f9df2136824f2e diff --git a/packages/client/ui-skill/README.md b/packages/client/ui-skill/README.md index a9506fe563..f70bd2780f 100644 --- a/packages/client/ui-skill/README.md +++ b/packages/client/ui-skill/README.md @@ -10,7 +10,7 @@ The `/client` export surface is the plugin body (`apply`/`inject`) only; the sou ## Skill tool row -The browser plugin also registers a keyed `skill` toolview in `conversation.chat.toolview`. A collapsed row renders the 16-pixel skill document-and-sparkle glyph, `Skill` title, separator, and requested skill name with the same neutral hierarchy as the Bash row; running calls carry the transcript shimmer, failures replace the name with the first error line, and interrupted calls use the warning state. A settled row expands as one whole-row disclosure into a bounded `Instructions` card containing the exact durable tool output, with the standard trajectory `Inspect` affordance when available. The row derives its name, lifecycle, and body only from a paired call/result slice in the current runtime window, never from the current catalog, so replay remains stable when installed skills or their descriptions change. +The browser plugin also registers a keyed `skill` toolview in `conversation.chat.toolview`. A collapsed row renders the 14-pixel skill document-and-sparkle glyph, `Skill` title, separator, and requested skill name with the same neutral hierarchy as the Bash row; running calls carry the transcript shimmer, failures replace the name with the first error line, and interrupted calls use the warning state. A settled row expands as one whole-row disclosure into a bounded `Instructions` card containing the exact durable tool output, with the standard trajectory `Inspect` affordance when available. The row derives its name, lifecycle, and body only from a paired call/result slice in the current runtime window, never from the current catalog, so replay remains stable when installed skills or their descriptions change. ## Model Experience diff --git a/packages/client/ui-skill/README.zh.md b/packages/client/ui-skill/README.zh.md index 6af5d3eb88..6eb6cbd3ae 100644 --- a/packages/client/ui-skill/README.zh.md +++ b/packages/client/ui-skill/README.zh.md @@ -10,7 +10,7 @@ skill(技能)引用 source 的浏览器端:把 `/` 触发的 `skill` sourc ## skill 工具行 -浏览器插件还会把一个 key 为 `skill` 的 toolview 注册进 `conversation.chat.toolview`。收起的行以与 Bash 行相同的中性色层级显示 16 像素的 skill 文档与闪光组合图标、`Skill` 标题、分隔符和请求加载的 skill 名称;运行中的调用带有 transcript(文本记录)的扫光效果,失败时用错误首行替换名称,中断调用则使用警告状态。已结算的行以整行作为展开入口,展开后显示一个尺寸受限的 `Instructions` 卡片,其中原样呈现持久化的工具输出;可用时还会提供标准执行轨迹的 `Inspect` 入口。该行的名称、生命周期和正文只派生自当前 runtime 窗口中已配对的调用/结果片段,绝不读取当前 skill 目录,因此即使已安装的 skill 或其描述发生变化,回放仍保持稳定。 +浏览器插件还会把一个 key 为 `skill` 的 toolview 注册进 `conversation.chat.toolview`。收起的行以与 Bash 行相同的中性色层级显示 14 像素的 skill 文档与闪光组合图标、`Skill` 标题、分隔符和请求加载的 skill 名称;运行中的调用带有 transcript(文本记录)的扫光效果,失败时用错误首行替换名称,中断调用则使用警告状态。已结算的行以整行作为展开入口,展开后显示一个尺寸受限的 `Instructions` 卡片,其中原样呈现持久化的工具输出;可用时还会提供标准执行轨迹的 `Inspect` 入口。该行的名称、生命周期和正文只派生自当前 runtime 窗口中已配对的调用/结果片段,绝不读取当前 skill 目录,因此即使已安装的 skill 或其描述发生变化,回放仍保持稳定。 ## 模型体验 diff --git a/packages/client/ui-skill/src/client/SkillRow.tsx b/packages/client/ui-skill/src/client/SkillRow.tsx index 076da55d52..65b474825a 100644 --- a/packages/client/ui-skill/src/client/SkillRow.tsx +++ b/packages/client/ui-skill/src/client/SkillRow.tsx @@ -82,7 +82,7 @@ function leadingFor(state: SkillRowState): ReactNode { switch (state) { case 'error': return case 'stopped': return - default: return + default: return } } diff --git a/packages/client/ui-skill/tests/skill-row.spec.tsx b/packages/client/ui-skill/tests/skill-row.spec.tsx index 4143b4a7a2..05b84ceda5 100644 --- a/packages/client/ui-skill/tests/skill-row.spec.tsx +++ b/packages/client/ui-skill/tests/skill-row.spec.tsx @@ -56,7 +56,7 @@ describe('SkillRow', () => { const row = screen.getByRole('button', { name: 'Skilldsh-manage-issues' }) expect(row.getAttribute('aria-expanded')).toBe('false') expect(view.container.querySelector('[data-tool="skill"]')?.getAttribute('data-state')).toBe('ok') - expect(view.container.querySelector('[data-tool="skill"] svg')?.getAttribute('width')).toBe('16') + expect(view.container.querySelector('[data-tool="skill"] svg')?.getAttribute('width')).toBe('14') expect(screen.queryByLabelText('说明')).toBeNull() fireEvent.click(row) From 5a90eb41fb1bc83417dc6de1573507b8b497dc25 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 7 Aug 2026 16:45:50 +0800 Subject: [PATCH 049/104] fix(ui-models): three faults the running app surfaced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **A hand-declared route must not offer a reasoning effort.** The earlier commit read the create card's missing control as drift and added one. It is the other way round: such a model has no reasoning capability — pi-ai's installed catalog is what supplies one, and it ships nothing under the route — so `resolveModel` throws UNSUPPORTED_REASONING_EFFORT for every model on it and the whole provider drops out of the picker. Verified against the adapter, not inferred. The create card no longer offers it and the editor withholds it on the directory's `declared` bit, which is the real bug: that control has always been wrong for these routes. **A blocked composer locked the way out of the block.** Reusing the no-workspace inert posture disabled the model seat along with everything else, so the bar asked for a model while preventing the one control that picks one. A block now rides its own `blocked` owner prop: the textarea, send, commands, plan seat, and access chip all lock, and the model seat alone stays live. **A Provider ID could derive an illegal credential reference.** The card accepted a digit-leading id, whose derived `123_API_KEY` then failed at the credential seam with a raw regular expression the user cannot act on. The id must now start with a letter, and a test pins the relation between the two rules rather than the regex. --- ...default-model-follows-the-picker.i18n.yaml | 4 +- ...-08-07-default-model-follows-the-picker.md | 2 +- ...-07-default-model-follows-the-picker.zh.md | 2 +- apps/web/tests/default-model.e2e.ts | 9 ++ apps/web/tests/models-settings.e2e.ts | 11 ++- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/contract/slots.ts | 8 ++ .../src/client/skeleton/ConversationRoot.tsx | 5 +- .../src/client/skeleton/InputBar.tsx | 12 ++- .../ui-conversation/tests/skeleton.spec.tsx | 21 ++++- packages/client/ui-models/README.i18n.yaml | 4 +- packages/client/ui-models/README.md | 2 +- packages/client/ui-models/README.zh.md | 2 +- .../src/client/CustomProviderCard.tsx | 33 ++++---- .../ui-models/src/client/ModelsSection.tsx | 4 + .../ui-models/src/client/ProviderEditor.tsx | 35 ++++++-- .../src/client/ReasoningEffortField.tsx | 17 ++-- .../client/ui-models/src/client/locales.ts | 8 +- .../ui-models/tests/provider-form.spec.tsx | 83 ++++++++++++------- 21 files changed, 179 insertions(+), 91 deletions(-) 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 40eb59e98f..f513e90666 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: 4142b3aea6a807001831df62c2038ddf57bbd6ad -2026-08-07-default-model-follows-the-picker.zh.md: c3566567781edac12cd9269d63f86528139c8796 +2026-08-07-default-model-follows-the-picker.md: d20f0ab8b8c8bd19f596e6ef73f0a58d96c24d38 +2026-08-07-default-model-follows-the-picker.zh.md: 0d2821cb63407fe766e6fe3d36de31d9fc6f1c13 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 4142b3aea6..d20f0ab8b8 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 @@ -30,7 +30,7 @@ A default naming a route the Models page has since removed leaves the composer s The Host refuses. `session.prompt` checks whether an adapter serves the session's route and answers `model-unavailable` before opening a turn. This is the enforcement boundary: a client that disables its composer is an affordance, and the method stays callable regardless. -The composer goes inert. `session.models` reports `routable`, and ui-model pushes a block through the new `ctx.conversation.blocks` registry; the bar renders the same disabled textarea it already renders without a workspace, with the blocker's own localized reason as the placeholder. The push direction is forced — ui-model already depends on ui-conversation, so ui-conversation cannot read it back. +The composer goes inert. `session.models` reports `routable`, and ui-model pushes a block through the new `ctx.conversation.blocks` registry; the bar renders the same disabled textarea it already renders without a workspace, with the blocker's own localized reason as the placeholder — except the model seat, which a block deliberately leaves live, because choosing a model is how the user clears it. The push direction is forced — ui-model already depends on ui-conversation, so ui-conversation cannot read it back. The gate is `routable`, NOT "the current target matches no advertised group". Catalog membership is advisory by design: a route serving a model it stopped advertising is absent from the groups yet perfectly usable, and blocking there would break a supported configuration (a narrowed `models` list over a live route). `routable` is also three-valued on the client — `null` before the first load or after a failed one never blocks, so a slow or unreachable Host cannot lock a working composer. 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 c356656778..0d2821cb63 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 @@ -30,7 +30,7 @@ Status: implemented 宿主拒绝。`session.prompt` 检查是否有适配器服务该会话的路由,在开启轮次之前就以 `model-unavailable` 应答。这是执行边界:客户端禁用编辑器只是提示性设计,这个方法始终可被调用。 -编辑器变惰性。`session.models` 报告 `routable`,ui-model 经新的 `ctx.conversation.blocks` 注册表推送一个 block;输入栏渲染的仍是它在没有 Workspace 时就会渲染的那个禁用 textarea,只是把抬起方自己的本地化理由作为 placeholder。推送方向是被迫的——ui-model 本就依赖 ui-conversation,因此 ui-conversation 读不回去。 +编辑器变惰性。`session.models` 报告 `routable`,ui-model 经新的 `ctx.conversation.blocks` 注册表推送一个 block;输入栏渲染的仍是它在没有 Workspace 时就会渲染的那个禁用 textarea,只是把抬起方自己的本地化理由作为 placeholder——唯独模型 seat 被 block 刻意保留可用,因为用户正是靠选模型来解除它。推送方向是被迫的——ui-model 本就依赖 ui-conversation,因此 ui-conversation 读不回去。 闸门是 `routable`,**不是**「当前目标匹配不到任何已公布分组」。目录成员关系按设计是咨询性的:一条仍在服务、只是不再公布该模型的路由不在分组里,却完全可用,在那里阻断会破坏一种受支持的配置(对一条活着的路由收窄 `models` 列表)。`routable` 在客户端还是三值的——首次加载之前或加载失败之后的 `null` 绝不阻断,因此慢的或够不着的宿主锁不死一个本来能用的编辑器。 diff --git a/apps/web/tests/default-model.e2e.ts b/apps/web/tests/default-model.e2e.ts index 9ff6198c3a..24ee5a1616 100644 --- a/apps/web/tests/default-model.e2e.ts +++ b/apps/web/tests/default-model.e2e.ts @@ -153,6 +153,15 @@ describe('web e2e: the composer model switch is the default for later sessions', }, }) expect(refused.result).toMatchObject({ ok: false, error: { code: 'model-unavailable' } }) + + // The way out stays open. Locking the model seat with everything else + // would leave the composer asking for the one thing it prevents. + const seat = page.getByRole('button', { name: /^选择模型/ }) + expect(await seat.isEnabled()).toBe(true) + await seat.click() + await page.getByRole('menuitem', { name: /模型/ }).click() + await page.getByRole('menuitemradio').first().click() + await expect.poll(async () => box.isEnabled(), { timeout: 15_000 }).toBe(true) expect(tripwire.pageErrors).toEqual([]) }, 60_000) }) diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 49b177c66d..6ebdbc1d3a 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -175,7 +175,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { expect(tripwire.pageErrors).toEqual([]) }, 60_000) - it('declares a route the adapter does not ship, with its own reasoning effort', async () => { + it('declares a route the adapter does not ship, without a reasoning control', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-models-declare')) const dialog = page.getByRole('dialog', { name: '设置' }) const declare = dialog.getByRole('button', { name: '添加自定义提供方' }) @@ -184,10 +184,10 @@ describe('web e2e: Models settings page configures a dormant provider', () => { await dialog.getByLabel('Provider ID').fill('acme-gateway') await dialog.getByLabel('显示名称').fill('Acme Gateway') await dialog.getByLabel('API 地址').fill('https://gateway.acme.example/v1') - // The create card offers the same provider-level effort the editor card - // does for this namespace; a route declared without it would gain the - // control only on reopening. - await dialog.getByLabel('推理强度').selectOption('high') + // No reasoning effort anywhere for a hand-declared route: its models carry + // no reasoning capability, so a profile effort would make every model on + // the route fail to resolve and drop the provider out of the picker. + expect(await dialog.getByLabel('推理强度').count()).toBe(0) await dialog.getByRole('button', { name: '添加模型' }).click() await dialog.getByLabel('模型 ID 1').fill('acme-large') await dialog.getByRole('button', { name: '创建提供方', exact: true }).click() @@ -196,7 +196,6 @@ describe('web e2e: Models settings page configures a dormant provider', () => { await row.waitFor({ timeout: 10_000 }) const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8') expect(document).toContain('acme-gateway:') - expect(document).toContain('reasoning: high') // The tag follows the adapter's installed catalog: this route is in no // catalog, while minimax-cn is — even though both now have profiles. diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 3af7b2fd75..40a885262e 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: 392f9956b33df88a5e9664a58de27d85fc0457d1 -README.zh.md: 6b0429a302475f84a7ce9b1cdc9fd47d90d6dba3 +README.md: ee8a4d240cdc326d158749ae8935ec99bb420d9f +README.zh.md: 64ac1d15e20a8b60a39a8beb9ae7695543250026 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 392f9956b3..ee8a4d240c 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -8,7 +8,7 @@ Compaction renders as one collapsed row at the checkpoint's flow position withou The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. The root always owns the same scrollport and Hero/composer subtree; separate strict-session header and body outlets fill their regions when the first Session arrives, so the Workspace picker, scroll body, composer seat, and textarea retain their React and DOM identity. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it the scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host. -Another plugin can make one session's composer inert through `ctx.conversation.blocks`: it sets a block carrying its own localized reason, and the bar renders the same disabled textarea with that reason as the placeholder — the no-workspace posture, reused. The push direction is the constraint, not a preference: the plugins that know a session cannot send (ui-model, when no adapter serves its route) already depend on this package, so this package cannot read them. A block is an affordance only; the Host refuses a prompt it cannot route regardless of what any client disables. The no-workspace state wins when both hold, because picking a workspace is the earlier prerequisite. +Another plugin can make one session's composer inert through `ctx.conversation.blocks`: it sets a block carrying its own localized reason, and the bar renders the same disabled textarea with that reason as the placeholder — the no-workspace posture, reused. The push direction is the constraint, not a preference: the plugins that know a session cannot send (ui-model, when no adapter serves its route) already depend on this package, so this package cannot read them. The model seat is the one control a block leaves live — every block this contract has is cleared by choosing a model, so locking it too would leave the composer asking for the only thing it prevents. A block is an affordance only; the Host refuses a prompt it cannot route regardless of what any client disables. The no-workspace state wins when both hold, because picking a workspace is the earlier prerequisite. The view ring is a slot: the strict session-body registration declares the session-scoped `'conversation.view'` list in its `children` table, that body renders the active entry through its renderSlot share (`only: `), and view tabs project from registration options (`id`/`order`/`label`). The chat view is this package's own entry; plugins such as ui-trajectory contribute tabs through `ctx.slots.register`, and each view owns its chrome. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 6b0429a302..64ac1d15e2 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -8,7 +8,7 @@ 常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。根组件始终拥有同一个滚动容器与 Hero/编辑器子树;首个会话到达时,彼此独立的严格会话页头和主体 outlet 只填入各自区域,因此 Workspace 选择器、滚动主体、编辑器 seat 与 textarea 都保留原有 React 和 DOM identity。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,仅显示当前会话标题和视图标签;fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。该滚动容器无条件预留自己的滚动条槽,选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md))。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。 -别的插件可以经 `ctx.conversation.blocks` 让某个会话的编辑器变为惰性:它设置一个携带自己本地化理由的 block,输入栏就渲染同一个禁用的 textarea,并把该理由作为 placeholder——复用无 Workspace 时的那套姿态。推送方向是约束而非偏好:知道某会话发不出消息的插件(ui-model,在没有适配器服务其路由时)本就依赖本包,因此本包读不到它们。block 只是提示性设计;无论客户端禁用了什么,宿主都会拒绝一个它路由不了的 prompt。两者同时成立时以无 Workspace 姿态为准,因为选 Workspace 是更靠前的前提。 +别的插件可以经 `ctx.conversation.blocks` 让某个会话的编辑器变为惰性:它设置一个携带自己本地化理由的 block,输入栏就渲染同一个禁用的 textarea,并把该理由作为 placeholder——复用无 Workspace 时的那套姿态。推送方向是约束而非偏好:知道某会话发不出消息的插件(ui-model,在没有适配器服务其路由时)本就依赖本包,因此本包读不到它们。模型 seat 是 block 唯一保留可用的控件——这份契约里的每个 block 都靠选模型来解除,把它一起锁上会让编辑器索要它自己拦下的那件事。block 只是提示性设计;无论客户端禁用了什么,宿主都会拒绝一个它路由不了的 prompt。两者同时成立时以无 Workspace 姿态为准,因为选 Workspace 是更靠前的前提。 视图环是一个 slot:严格会话主体注册在 `children` 表中声明 Session scope 的 `'conversation.view'` 列表,并通过自身的 renderSlot share 渲染活跃配置项(`only: `);视图标签页则从注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包自身的配置项;ui-trajectory 等插件通过 `ctx.slots.register` 贡献标签页,每个视图负责自己的 chrome。 diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 4c3a1546c9..84fb39cec8 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -265,6 +265,14 @@ export interface ConversationSessionHeaderInjected { export interface ComposerBarOwnerProps { /** Hero = empty-state centered card; composer = resident bottom bar. */ variant: 'hero' | 'composer' + /** + * A block another plugin raised for this session: the bar refuses input and + * shows the blocker's reason as the placeholder, but — unlike `disabled` — + * keeps the model seat live. Every block this contract has is one the user + * clears by choosing a model, so locking that seat too would leave the + * composer telling them to do the one thing it prevents. + */ + blocked?: { readonly reason: string } /** * Inert no-workspace state: the bar renders its normal DOM fully disabled * (textarea, add, send) so the workspace pick transitions in place instead diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index 377b4de3ec..8440dacd94 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -138,7 +138,10 @@ export function ConversationRoot({ ...(inert ? { disabled: true, placeholder: t('placeholder.workspace') } : blocked - ? { disabled: true, placeholder: composerBlock.reason } + // `blocked`, not `disabled`: the bar refuses input either way, but a + // block keeps the model seat live because choosing a model is how the + // user clears it. + ? { blocked: composerBlock, placeholder: composerBlock.reason } : hero ? { placeholder: t('placeholder.hero') } : {}), overlay: renderSlot('conversation.input.overlay', {}), leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone), diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 131f63c49d..7b24c09684 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -37,7 +37,8 @@ export type InputBarProps = ComposerBarProps export function InputBar({ useSession, useInput, inputActions, keyboard, resolveSubmitMode, toggleCommandMenu, stop, command, t, renderSlot, useNotices, useLexicon, useMenuLauncher, - useProjection, sessionId, variant, disabled: inert = false, placeholder, accessory, overlay, leftItems, rightItems, footer, + useProjection, sessionId, variant, disabled: inert = false, blocked, placeholder, + accessory, overlay, leftItems, rightItems, footer, }: InputBarProps) { const input = useInput(s => s) const notice = useNotices(s => s) @@ -86,8 +87,13 @@ export function InputBar({ // inert no-workspace state, or the machine faces absent (no session). The // transient machine locks (adjudicating pending / submitting) render // read-only — the draft stays visible and focused, keystrokes drop. - const disabled = removed || inert || !live + const disabled = removed || inert || !live || blocked !== undefined const locked = disabled + // The model seat is the ONE control a block leaves live: every block this + // contract has is cleared by choosing a model, so locking it too would leave + // the composer asking for the only thing it prevents. The other reasons to + // be disabled do lock it — there is no session to choose a model for. + const modelSeatLocked = removed || inert || !live const machineBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting' // Scroll the draft scrollport the minimum that brings `caret` into view — the @@ -512,7 +518,7 @@ export function InputBar({
    {rightItems} - {renderSlot('conversation.input.model', { locked })} + {renderSlot('conversation.input.model', { locked: modelSeatLocked })} {/* {machineBusy && } */} diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index e0872f1539..bcd8f25e73 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -120,9 +120,14 @@ function mount( const stop = vi.fn() const open = vi.fn() const slotCalls: string[] = [] + /** Owner share handed to the two composer tool-row seats, per render. */ + const seatOwners: { key: string; owner: unknown }[] = [] let pickerOwner: unknown const renderSlot = ((key: string, owner: object, opts?: { only?: string }) => { slotCalls.push(key) + if (key === 'conversation.input.model' || key === 'conversation.input.plan') { + seatOwners.push({ key, owner }) + } if (key === 'conversation.hero.workspace') { pickerOwner = owner; return null } if (key === 'conversation.session.header') { return ( @@ -200,7 +205,12 @@ function mount( stop={stop} command={() => Promise.resolve(true)} t={t} - renderSlot={(() => null) as InputBarProps['renderSlot']} + renderSlot={((key: string, seatOwner: object) => { + // The bar's own seats: recorded so a case can assert what share + // each tool-row control received. + seatOwners.push({ key, owner: seatOwner }) + return null + }) as InputBarProps['renderSlot']} {...bar} /> ) @@ -236,7 +246,7 @@ function mount( } const view = render() return { - view, chat, sink, retargetWorkspace, session, slotCalls, open, + view, chat, sink, retargetWorkspace, session, slotCalls, seatOwners, open, pickerOwner: () => pickerOwner, rerender: () => { view.rerender() }, } @@ -262,6 +272,13 @@ describe('ConversationRoot resident composer', () => { expect(box.placeholder).toBe('select a model first') fireEvent.keyDown(box, { key: 'Enter' }) expect(b.sink).not.toHaveBeenCalled() + + // The model seat stays live. Locking it too would leave the composer + // asking for the one thing it prevents — every block this contract has is + // cleared by choosing a model. + const seat = (key: string) => b.seatOwners.filter(call => call.key === key).at(-1)?.owner + expect(seat('conversation.input.model')).toEqual({ locked: false }) + expect(seat('conversation.input.plan')).toEqual({ locked: true }) }) it('lets the no-workspace posture win over a block', () => { diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index 0dbea8d48c..e2b9e45f9d 100644 --- a/packages/client/ui-models/README.i18n.yaml +++ b/packages/client/ui-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-models/README.md -README.md: 1d9c98dfd1e0cec0fa4cb33df9ffe2640be8be05 -README.zh.md: d3437c6f13be49ea73d6b3a51bee664b32521e01 +README.md: dec43de43899ef99e74b1fd73ffb4bf3c4e97b3e +README.zh.md: c17eb611f071c4054d12d36acb2de8a94fa95a20 diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index 1d9c98dfd1..dec43de438 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -16,7 +16,7 @@ A pi-ai profile's `models` list is edited on the card: one row per model showing **Fetch available models** asks `llm.discoverModels` about the endpoint the form **currently shows**, including a base URL edited but not yet saved and a key typed but not yet stored, so adding a provider is one pass instead of save-then-return. The reply opens a picker rather than being written: candidates already configured start unchecked, so adopting a selection never overwrites a capacity the user corrected. A provider that cannot be interrogated is a detour, not a dead end — the adapter's own message appears beside the rows, which stay editable by hand. -**Add a custom provider** declares a route pi-ai does not ship. It is its own card rather than the editor with extra fields, because the route id is being chosen here and the settings address does not exist until it is: one `settings.mutate` sets the whole profile at `providers.`, and the key travels separately through `credentials.set` under the same `_API_KEY` derivation an existing provider uses. What a hand-declared route cannot default gates the create button — a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model — so the failure names the field while the user is still looking at it. Capacities do not gate it: the adapter's fallbacks size a model the endpoint described by id alone, which is what most listings return. The protocol choices are read out of the namespace's own schema rather than a wire field or a constant, so they cannot drift from the ones the adapter accepts. The card records the conventional `apiKeyEnv` reference only when a key is typed, the same rule the editor applies, so a route declared for provider-native authentication is not born pointing at a reference nothing will ever set. When the profile write lands but the key write fails, the provider already exists: the card settles the fields describing it, retries the credential alone — re-running the profile write would carry the revision that write just superseded, so the Host would answer `settings-conflict` and the key could never be stored from here — and reports the created provider even if the user then cancels. The card offers the same provider-level reasoning effort the editor card does for this namespace, from one shared control: both write the same profile field, so a route declared without it would have gained the setting only on being reopened. +**Add a custom provider** declares a route pi-ai does not ship. It is its own card rather than the editor with extra fields, because the route id is being chosen here and the settings address does not exist until it is: one `settings.mutate` sets the whole profile at `providers.`, and the key travels separately through `credentials.set` under the same `_API_KEY` derivation an existing provider uses. What a hand-declared route cannot default gates the create button — a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model — so the failure names the field while the user is still looking at it. The id must start with a lowercase letter, because it is also the stem of the derived credential reference and a reference is a POSIX shell identifier: a digit-leading id otherwise passes every check this card makes and then fails at the credential seam with a raw regular expression. Capacities do not gate it: the adapter's fallbacks size a model the endpoint described by id alone, which is what most listings return. The protocol choices are read out of the namespace's own schema rather than a wire field or a constant, so they cannot drift from the ones the adapter accepts. The card records the conventional `apiKeyEnv` reference only when a key is typed, the same rule the editor applies, so a route declared for provider-native authentication is not born pointing at a reference nothing will ever set. When the profile write lands but the key write fails, the provider already exists: the card settles the fields describing it, retries the credential alone — re-running the profile write would carry the revision that write just superseded, so the Host would answer `settings-conflict` and the key could never be stored from here — and reports the created provider even if the user then cancels. Neither this card nor the editor offers a reasoning effort for such a route: a hand-declared model carries no reasoning capability — pi-ai's installed catalog is what supplies one, and it ships nothing under this route — so a profile effort makes `resolveModel` throw for every model on the route and drops the whole provider out of the picker. The editor withholds the control on the directory's `declared` bit for exactly that reason; a route the adapter ships keeps it. ## Model Experience diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index d3437c6f13..c17eb611f0 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -16,7 +16,7 @@ pi-ai profile 的 `models` 列表就在卡片上编辑:一行一个模型, **获取可用模型**会针对表单**当前显示**的端点调用 `llm.discoverModels`,包括已修改但尚未保存的 API 地址和已键入但尚未存储的密钥,因此新增一个提供方是一趟走完,而不是「先保存再回来」。回复会打开一个选择框而不是直接写入:已配置过的候选默认不勾选,因此采纳一次选择绝不会覆盖用户已更正的容量。无法被询问的提供方只是绕路而非死路——适配器自己的消息会显示在各行旁边,而这些行仍可手工编辑。 -**添加自定义提供方**用来声明 pi-ai 未提供的路由。它是独立的一张卡片而非在编辑器上加字段,因为路由 id 正是在这里被*选定*的,而在选定之前 settings 地址并不存在:一次 `settings.mutate` 在 `providers.` 上设置整个 profile,密钥则经 `credentials.set` 单独传递,使用与既有提供方相同的 `_API_KEY` 派生。手工声明的路由无法默认的东西会门控创建按钮——唯一的 **Provider ID**、端点、协议,以及至少一个由唯一标识的模型——因此失败会在用户仍看着该字段时点名它。容量不参与门控:端点只按 id 描述的模型(这正是多数列表返回的形态)由适配器的回退值定尺寸。协议选项读自该 namespace 自己的 schema,而非某个协议字段或常量,因此它们不会与适配器实际接受的集合发生漂移。只有键入了密钥,这张卡片才记录约定的 `apiKeyEnv` 引用,与编辑器同一条规则,因此一条为提供方原生认证声明的路由不会一出生就指向一个永远不会被设置的引用。当 profile 写入成功而密钥写入失败时,提供方其实已经存在:卡片会把描述它的字段定住,只重试凭据——再跑一次 profile 写入会带着刚被自己这次写入取代的 revision,宿主将以 `settings-conflict` 应答,密钥就再也无法从这里存下——并且即使用户随后取消,也照实报告提供方已创建。这张卡片提供与编辑器卡片在该 namespace 下相同的提供方级推理等级,两者共用同一个控件:它们写的是同一个 profile 字段,若声明时没有它,这个设置就会等到重新打开编辑时才凭空出现。 +**添加自定义提供方**用来声明 pi-ai 未提供的路由。它是独立的一张卡片而非在编辑器上加字段,因为路由 id 正是在这里被*选定*的,而在选定之前 settings 地址并不存在:一次 `settings.mutate` 在 `providers.` 上设置整个 profile,密钥则经 `credentials.set` 单独传递,使用与既有提供方相同的 `_API_KEY` 派生。手工声明的路由无法默认的东西会门控创建按钮——唯一的 **Provider ID**、端点、协议,以及至少一个由唯一标识的模型——因此失败会在用户仍看着该字段时点名它。该 id 必须以小写字母开头,因为它同时是派生凭据引用的词干,而引用是 POSIX shell 标识符:数字开头的 id 否则会通过这张卡片的每一项检查,然后在凭据 seam 上以一条用户无从下手的原始正则失败。容量不参与门控:端点只按 id 描述的模型(这正是多数列表返回的形态)由适配器的回退值定尺寸。协议选项读自该 namespace 自己的 schema,而非某个协议字段或常量,因此它们不会与适配器实际接受的集合发生漂移。只有键入了密钥,这张卡片才记录约定的 `apiKeyEnv` 引用,与编辑器同一条规则,因此一条为提供方原生认证声明的路由不会一出生就指向一个永远不会被设置的引用。当 profile 写入成功而密钥写入失败时,提供方其实已经存在:卡片会把描述它的字段定住,只重试凭据——再跑一次 profile 写入会带着刚被自己这次写入取代的 revision,宿主将以 `settings-conflict` 应答,密钥就再也无法从这里存下——并且即使用户随后取消,也照实报告提供方已创建。这类路由在两张卡片上都不提供推理等级:手工声明的模型没有推理能力——能力来自 pi-ai 的已安装 catalog,而它在这条路由下什么都没有——因此 profile 级等级会让该路由上每个模型的 `resolveModel` 抛错,整个提供方从选择器里消失。编辑器正是依据目录的 `declared` 位收起这个控件;适配器自带的路由则保留它。 ## 模型体验 diff --git a/packages/client/ui-models/src/client/CustomProviderCard.tsx b/packages/client/ui-models/src/client/CustomProviderCard.tsx index 14511864ad..f8dd6ca3f8 100644 --- a/packages/client/ui-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-models/src/client/CustomProviderCard.tsx @@ -13,6 +13,14 @@ * The three fields a hand-declared route cannot default — endpoint, protocol, * and at least one model — are required here rather than at load, so the * failure names the field while the user is still looking at it. + * + * There is deliberately no reasoning-effort control. A hand-declared model + * carries no reasoning capability — pi-ai's installed catalog is what supplies + * one, and it has nothing under this route — so a profile effort here makes + * `resolveModel` throw UNSUPPORTED_REASONING_EFFORT for every model on the + * route, which drops the whole provider out of the model picker. The editor + * card hides the control for the same reason once the directory reports the + * route as declared. */ import { useState } from 'react' @@ -23,7 +31,6 @@ import { EditorFooter } from './EditorFooter.tsx' import { validateDeepSeekModels } from './DeepSeekModelsEditor.tsx' import { ModelListEditor } from './ModelListEditor.tsx' import type { ModelDraft } from './ModelListEditor.tsx' -import { EFFORT_FIELD, ReasoningEffortField } from './ReasoningEffortField.tsx' import { deriveKeyRef, messageOf } from './store.ts' import type { en } from './locales.ts' import styles from './ModelsSection.module.css' @@ -31,8 +38,15 @@ import styles from './ModelsSection.module.css' /** The settings namespace a hand-declared provider is written into. */ const NS = 'llm-pi-ai' -/** A route id usable as a settings key and as the stem of a credential name. */ -const ROUTE_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ +/** + * A route id usable as a settings key AND as the stem of a credential name. + * The leading letter is the second half of that: `deriveKeyRef` uppercases the + * id and replaces every non-alphanumeric run with `_`, and a credential + * reference is a POSIX shell identifier, which cannot start with a digit. A + * digit-leading id passes every check this card makes and then fails at the + * credential seam with a raw regular expression the user cannot act on. + */ +const ROUTE_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/ /** Props of {@link CustomProviderCard}. */ export interface CustomProviderCardProps { @@ -71,7 +85,6 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { const [baseURL, setBaseURL] = useState('') const [protocol, setProtocol] = useState(protocols[0] ?? '') const [keyDraft, setKeyDraft] = useState('') - const [effort, setEffort] = useState(undefined) const [models, setModels] = useState([]) const [busy, setBusy] = useState(false) const [failure, setFailure] = useState(undefined) @@ -128,9 +141,6 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { ...storesKey ? { apiKeyEnv: keyRef } : {}, api: protocol, baseURL, - // Inherit is the field being absent, not an empty string: the schema - // types it as an effort name, and an empty one would fail the write. - ...effort === undefined ? {} : { [EFFORT_FIELD['pi-ai']]: effort }, models: models.map(model => ({ ...model })), } const response = await api.settings.mutate({ @@ -251,15 +261,6 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { ? null :

    {t(keyFailure === 'keyBlank' ? 'keyBlankNew' : keyFailure)}

    }
    - {/* The same control the editor card shows for this namespace: a route - declared here and edited there must offer the same profile. */} - ) @@ -137,6 +140,7 @@ function targetOf(row: ProviderRow): EditorTarget { settingsNs: row.entry.settingsNs, settingsPath: row.entry.settingsPath, ...credentialRef === undefined ? {} : { credentialRef }, + ...row.entry.declared === undefined ? {} : { declared: row.entry.declared }, } } diff --git a/packages/client/ui-models/src/client/ProviderEditor.tsx b/packages/client/ui-models/src/client/ProviderEditor.tsx index 9b86db062a..3e6b26658f 100644 --- a/packages/client/ui-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-models/src/client/ProviderEditor.tsx @@ -7,8 +7,10 @@ * a key is entered; a blank key materializes a reference-free profile for * provider-native authentication); * the collapsed 自定义设置 area carries the per-family extras (`baseURL` for - * both families, `reasoningEffort` for deepseek / `reasoning` for pi-ai, and - * DeepSeek's id/name/context-window model catalog). Everything else stays + * both families, `reasoningEffort` for deepseek / `reasoning` for pi-ai — + * withheld for a hand-declared route, whose models have no reasoning + * capability to configure — and DeepSeek's id/name/context-window model + * catalog). Everything else stays * owned by `settings.yaml`. Profile edits land as minimal `settings.mutate` * path ops against the stored section — the card reads the redacted * descriptor, so it names only the fields it can see and a stored literal @@ -55,6 +57,13 @@ export interface ProviderEditorProps { api: Pick /** Section copy. */ t: (key: keyof typeof en) => string + /** + * Whether the owning adapter knows this route only because configuration + * declared it. Such a route's models carry no reasoning capability, so the + * effort control is withheld; absent means the adapter draws no such + * distinction and the control shows. + */ + declared?: boolean /** Disable writes (read-only settings provider). */ readOnly: boolean /** Close the editor; `changed` reports whether an Apply committed. */ @@ -352,13 +361,21 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { }} /> - { setField(effortField, effort) }} - t={t} - disabled={disabled} - /> + {/* A hand-declared route's models carry no reasoning capability + (pi-ai's installed catalog is what supplies one, and it has + nothing under such a route), so a profile effort would make + `resolveModel` throw for every model on it and drop the whole + provider out of the picker. Offering the control at all would + be offering a way to break the route. */} + {props.declared === true ? null : ( + { setField(effortField, effort) }} + t={t} + disabled={disabled} + /> + )} {/* Both families edit the same rows through the same contract; only the extras differ — DeepSeek's inherited capacities, pi-ai's endpoint interrogation. */} diff --git a/packages/client/ui-models/src/client/ReasoningEffortField.tsx b/packages/client/ui-models/src/client/ReasoningEffortField.tsx index 10b696a4ea..a129637135 100644 --- a/packages/client/ui-models/src/client/ReasoningEffortField.tsx +++ b/packages/client/ui-models/src/client/ReasoningEffortField.tsx @@ -1,13 +1,14 @@ /** - * The provider-level reasoning-effort select, shared by every card that writes - * a provider profile. It lives here rather than inside one card because both - * write the SAME field of the same profile: a route declared without this - * control and then edited with it would offer a setting the creating user was - * never given, which is exactly the drift that put it here. + * The provider-level reasoning-effort select: the profile's own default + * effort, applied to every model on the route unless a request names one. The + * empty option means "inherit", which on the wire is the field being absent + * rather than an empty string. * - * The value is the profile's own default effort, applied to every model on the - * route unless a request names one; the empty option means "inherit", which on - * the wire is the field being absent rather than an empty string. + * It carries the per-family vocabulary and field name so the editor's two + * layouts cannot spell them differently. Only routes the adapter ships get + * this control at all — a hand-declared model has no reasoning capability to + * configure, and a profile effort over one makes its whole route fail to + * resolve — so the create card renders nothing here by construction. */ import type { ReactNode } from 'react' diff --git a/packages/client/ui-models/src/client/locales.ts b/packages/client/ui-models/src/client/locales.ts index 67e48c3890..9809c61283 100644 --- a/packages/client/ui-models/src/client/locales.ts +++ b/packages/client/ui-models/src/client/locales.ts @@ -77,8 +77,8 @@ export const en = { customTitle: 'Custom provider', customTag: 'Custom', customRoute: 'Provider ID', - customRouteHint: 'Lowercase identifier that uniquely names this provider in requests and as its credential name.', - customRouteInvalid: 'Use lowercase letters, digits, and dashes.', + customRouteHint: 'Lowercase identifier, starting with a letter, that uniquely names this provider in requests and as its credential name.', + customRouteInvalid: 'Start with a lowercase letter; then lowercase letters, digits, and dashes.', customRouteTaken: 'A provider already uses this ID.', customDisplayName: 'Display name', customApi: 'API protocol', @@ -172,8 +172,8 @@ export const zh: typeof en = { customTitle: '自定义提供方', customTag: '自定义', customRoute: 'Provider ID', - customRouteHint: '小写标识,在请求中唯一标识该提供方,并用于派生凭据名。', - customRouteInvalid: '只能使用小写字母、数字和短横线。', + customRouteHint: '以小写字母开头的标识,在请求中唯一标识该提供方,并用于派生凭据名。', + customRouteInvalid: '需以小写字母开头,之后可用小写字母、数字和短横线。', customRouteTaken: '已有提供方使用了这个 ID。', customDisplayName: '显示名称', customApi: 'API 协议', diff --git a/packages/client/ui-models/tests/provider-form.spec.tsx b/packages/client/ui-models/tests/provider-form.spec.tsx index 95bfda4bb0..831353bc5c 100644 --- a/packages/client/ui-models/tests/provider-form.spec.tsx +++ b/packages/client/ui-models/tests/provider-form.spec.tsx @@ -9,7 +9,7 @@ import { ModelsSection } from '../src/client/ModelsSection.tsx' import type { ModelsSectionInjected } from '../src/client/ModelsSection.tsx' import { CustomProviderCard } from '../src/client/CustomProviderCard.tsx' import { formatCapacity, parseCapacity } from '../src/client/DeepSeekModelsEditor.tsx' -import { ModelsSettingsStore, protocolChoices } from '../src/client/store.ts' +import { ModelsSettingsStore, deriveKeyRef, protocolChoices } from '../src/client/store.ts' import { en } from '../src/client/locales.ts' afterEach(cleanup) @@ -705,38 +705,35 @@ describe('hand-declared providers', () => { expect(set).toHaveBeenCalledWith({ ref: 'ACME_GATEWAY_API_KEY', value: 'gw-key' }) }) - it('offers the same reasoning effort the editor does, and omits it when inherited', async () => { - const { mutate, onClose } = mountCard() - const declare = (): void => { - fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } }) - fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } }) - fireEvent.click(screen.getByRole('button', { name: en.addModel })) - fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } }) - } - declare() + it('offers no reasoning effort at all, in either card, for a hand-declared route', async () => { + mountCard() + fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } }) + fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } }) + // A hand-declared model carries no reasoning capability — pi-ai's + // installed catalog is what supplies one, and it ships nothing under this + // route — so a profile effort makes `resolveModel` throw + // UNSUPPORTED_REASONING_EFFORT for every model on it and drops the whole + // provider out of the picker. Offering the control would be offering a way + // to break the route. + expect(screen.queryByLabelText(en.effort)).toBeNull() + cleanup() - // The vocabulary is the namespace's, not DeepSeek's — a route declared - // here is edited by the pi-ai layout, which offers exactly these. - const select = screen.getByLabelText(en.effort) as HTMLSelectElement + // The editor card withholds it for the same route for the same reason... + await mountSection({ + providers: { 'acme-gateway': { apiKeyEnv: 'ACME_GATEWAY_API_KEY', baseURL: 'https://acme.test/v1' } }, + declaredRoutes: ['acme-gateway'], + }) + openEditor('acme-gateway') + expect(screen.queryByLabelText(en.effort)).toBeNull() + cleanup() + + // ...and keeps it for a route the adapter actually ships, whose models do + // carry the capability. + await mountSection({ providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY' } } }) + openEditor('openai') + const select = screen.getByLabelText(en.effort) expect([...select.options].map(option => option.value)) .toEqual(['', 'off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']) - - fireEvent.change(select, { target: { value: 'high' } }) - fireEvent.click(screen.getByText(en.create)) - await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) }) - expect(firstMutate(mutate).ops[0]).toMatchObject({ - path: ['providers', 'acme'], - value: { reasoning: 'high' }, - }) - - // Inherit is the field being absent: an empty string would fail the schema - // that types this as an effort name. - cleanup() - const second = mountCard() - declare() - fireEvent.click(screen.getByText(en.create)) - await waitFor(() => { expect(second.onClose).toHaveBeenCalledWith(true) }) - expect(firstMutate(second.mutate).ops[0]).not.toHaveProperty('value.reasoning') }) it('retries only the key after the profile landed, and reports the provider on cancel', async () => { @@ -793,6 +790,32 @@ describe('hand-declared providers', () => { expect(onClose).toHaveBeenCalledWith(true) }) + it('refuses a route id whose derived credential reference would be illegal', () => { + mountCard() + const routeField = screen.getByLabelText(en.customRoute) + fireEvent.change(routeField, { target: { value: 'https://acme.test/v1' } }) + + // A digit-leading id used to pass every check this card makes and then + // fail at the credential seam with a raw regular expression: the + // reference derives as `123_API_KEY`, and a credential reference is a + // POSIX shell identifier, which cannot start with a digit. + fireEvent.change(routeField, { target: { value: '123' } }) + expect(screen.getByText(en.customRouteInvalid)).toBeTruthy() + expect(buttonNamed(en.create).disabled).toBe(true) + + fireEvent.change(routeField, { target: { value: 'a1' } }) + expect(screen.queryByText(en.customRouteInvalid)).toBeNull() + }) + + it('derives a reference the credential seam accepts for every id it admits', () => { + // The two rules have to stay in step; this is the relation, checked + // directly rather than through the DOM. + const CREDENTIAL_REF = /^[A-Za-z_][A-Za-z0-9_]*$/ + for (const id of ['a', 'ds', 'a1', 'acme-gateway', 'x-1-y', 'zz9']) { + expect(CREDENTIAL_REF.test(deriveKeyRef(id))).toBe(true) + } + }) + it('names the blocked gate under the form, and nothing once it is satisfied', () => { mountCard() fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } }) From 135064c8314dc7875bb1d1a17bb2c85a7ab1448e Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 7 Aug 2026 17:02:05 +0800 Subject: [PATCH 050/104] fix(ui-models): stop the shared hint contradicting a filled-in field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The line under the create form names the one blocked gate worth naming, and its fallback arm reads "no models yet". An unmet Provider ID gate fell through to that arm, so a card with two models listed right above it was told it needed one. The key gate was already excluded for this reason; the route gate was assumed excluded because its field explains itself, and was not. Tightening the route rule in the previous commit is what made this easy to hit — a digit-leading id now fails the gate — but the fallthrough predates it and fires for an empty or taken id just the same. --- .../src/client/CustomProviderCard.tsx | 7 +++++-- .../ui-models/tests/provider-form.spec.tsx | 20 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-models/src/client/CustomProviderCard.tsx b/packages/client/ui-models/src/client/CustomProviderCard.tsx index f8dd6ca3f8..e27bd3c6bd 100644 --- a/packages/client/ui-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-models/src/client/CustomProviderCard.tsx @@ -112,14 +112,17 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { const ready = route.length > 0 && !routeInvalid && !routeTaken && baseURL.length > 0 && models.length > 0 && modelFailure === undefined && keyFailure === undefined - // The one blocked gate worth a line under the form. The route id is omitted - // because its own field already explains itself, and a satisfied card says + // The one blocked gate worth a line under the form. A satisfied card says // nothing at all rather than printing an empty paragraph. const hint = failure !== undefined || ready // The key field prints its own failure directly beneath itself, so a card // blocked only by the key stays silent here rather than answering with the // next unmet gate — which is satisfied, and reads as a second, false fault. || keyFailure !== undefined + // Same for the route id, and it must be tested rather than assumed: the + // fallback arm below reads "no models yet", so an unmet route gate used to + // fall through to it and contradict the filled-in list right above. + || route.length === 0 || routeInvalid || routeTaken ? undefined : baseURL.length === 0 ? t('customNeedsBaseUrl') diff --git a/packages/client/ui-models/tests/provider-form.spec.tsx b/packages/client/ui-models/tests/provider-form.spec.tsx index 831353bc5c..7d8f2efe27 100644 --- a/packages/client/ui-models/tests/provider-form.spec.tsx +++ b/packages/client/ui-models/tests/provider-form.spec.tsx @@ -790,6 +790,26 @@ describe('hand-declared providers', () => { expect(onClose).toHaveBeenCalledWith(true) }) + it('never contradicts a filled-in field with the next gate\u2019s copy', () => { + mountCard() + const routeField = screen.getByLabelText(en.customRoute) + fireEvent.change(routeField, { target: { value: '2' } }) + fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } }) + fireEvent.click(screen.getByRole('button', { name: en.addModel })) + fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } }) + + // The route field explains itself right under the input; the shared line + // must stay silent rather than falling through to "no models yet" while + // the list above plainly has one. + expect(screen.getByText(en.customRouteInvalid)).toBeTruthy() + expect(screen.queryByText(en.customNeedsModels)).toBeNull() + + // Fixing the route hands the line back to the gate that is actually unmet. + fireEvent.change(routeField, { target: { value: 'acme' } }) + expect(screen.queryByText(en.customNeedsModels)).toBeNull() + expect(buttonNamed(en.create).disabled).toBe(false) + }) + it('refuses a route id whose derived credential reference would be illegal', () => { mountCard() const routeField = screen.getByLabelText(en.customRoute) From 2efce69d7921a0b2e1610f15d6efb5344b3b5b83 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:15:27 +0800 Subject: [PATCH 051/104] fix(client): route turn-tail through chain selector --- .../ui-conversation/src/client/apply.ts | 2 +- .../src/client/chat/AssistantMarkdown.tsx | 14 ++++----- .../src/client/chat/ChatView.tsx | 6 ++-- .../src/client/contract/slots.ts | 11 ++++--- .../ui-conversation/tests/chat-view.spec.tsx | 4 ++- .../src/client/ProducedFiles.tsx | 29 +++++++------------ .../ui-deliverables/src/client/index.ts | 4 +-- .../src/client/turn-deliverables.ts | 11 +++++++ .../tests/produced-files.spec.tsx | 15 ++++------ 9 files changed, 48 insertions(+), 48 deletions(-) diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 8eb78139c4..24325c714e 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -303,7 +303,7 @@ export function apply(ctx: Context): void { children: { 'conversation.chat.toolview': { kind: 'keyed', scope: 'session' }, 'conversation.chat.commandview': { kind: 'keyed', scope: 'session' }, - 'conversation.chat.turnTail': { kind: 'list', scope: 'session' }, + 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' }, }, store: chatStore, inject: (sessionId: SessionId, actions: BoundActions): ChatViewInjected => { diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index 687e1ae86c..bc1c6c7e32 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -9,12 +9,13 @@ // their branch action is enabled only when the node is also the completed // turn's transcript tail. Think / tool-head-only nodes stay chrome-free. -import { memo, useMemo, type ReactNode } from 'react' +import { memo, useMemo } from 'react' import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' +import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { IconThinkOutline14, JsonBlock, MarkdownText, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ChatViewSlotProps } from '../contract/slots.ts' +import type { ChatViewSlotProps, TurnTailOwnerProps } from '../contract/slots.ts' import { hasContentText } from './chat-flow.ts' import { MessageIconActions } from './MessageIconActions.tsx' import { ToolRow } from './ToolRow.tsx' @@ -40,9 +41,8 @@ export interface AssistantMarkdownProps { seq?: number | undefined /** Fork the session through this finalized message's completed turn when eligible. */ onFork?: ((seq: number) => void) | undefined - /** Turn-tail content (the chat view's turnTail hole, rendered by the - * owner); omitted for a mid-turn assistant. */ - tail?: ReactNode | undefined + /** Turn-tail slot dispatch share and owner currency; omitted for a mid-turn assistant. */ + turnTail?: (Pick, 'renderSlotChain'> & { owner: TurnTailOwnerProps }) | undefined /** The message is not the transcript tail of a completed turn. */ forkUnavailable?: boolean | undefined /** The owning view's locale seat, passed down as a plain prop. */ @@ -86,7 +86,7 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass } export const AssistantMarkdown = memo(function AssistantMarkdown({ - blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, tail, t, + blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, turnTail, t, }: AssistantMarkdownProps) { // Stable per locale revision (t identity changes on switch): a fresh object // per render would rebuild MarkdownText's component table every chunk. @@ -124,7 +124,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ })} {interrupted && {t('message.stopped')}} - {showActions && tail} + {showActions && turnTail?.renderSlotChain('conversation.chat.turnTail', turnTail.owner)} {showActions && ( s.nodes) const turnTimings = useSession(s => s.turnTimings) @@ -600,8 +600,8 @@ export function ChatView({ seq={node.seq} onFork={forkAt} forkUnavailable={!branchSeqs.has(node.seq)} - tail={actionSeqs.has(node.seq) - ? renderSlot('conversation.chat.turnTail', { nodes, seq: node.seq, openFile }) + turnTail={actionSeqs.has(node.seq) + ? { renderSlotChain, owner: { nodes, seq: node.seq, openFile } } : undefined} t={t} /> diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 1246433a33..89f7986dba 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -47,14 +47,13 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { */ 'conversation.chat.commandview': { kind: 'keyed'; scope: 'session'; owner: CommandRowOwnerProps } /** - * The chat view's turn-tail hole: rendered between a closing assistant + * The chat view's turn-tail chain: rendered between a closing assistant * message's body and its IconActions footer, once per turn (the render - * site elects the closing seq). Declared by the chat view entry; feature - * plugins (ui-deliverables' produced-files row) derive what they show - * from the owner currency, and an unregistered hole renders nothing — - * composing such a plugin out of cordis.yml turns its surface off. + * site elects the closing seq). Entries derive a match from the owner + * currency before mounting, so presentation components never mount only + * to return null; an all-declined chain renders nothing. */ - 'conversation.chat.turnTail': { kind: 'list'; scope: 'session'; owner: TurnTailOwnerProps } + 'conversation.chat.turnTail': { kind: 'chain'; scope: 'session'; owner: TurnTailOwnerProps } /** * The composer takeover chain: entries are selector-routed replacements * of the default InputBar. Declared by this package's 'conversation' diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index f2bb8709d7..b8cd94de52 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -130,6 +130,8 @@ function makeHarness(init?: Partial) { const chat = createChatStore().create() const renderSlot = ((_key: string, _owner: object, opts?: { fallback?: React.ReactNode }) => opts?.fallback ?? null) as unknown as ChatViewSlotProps['renderSlot'] + const renderSlotChain = ((_key: string, _owner: object, opts?: { fallback?: React.ReactNode }) => + opts?.fallback ?? null) as unknown as ChatViewSlotProps['renderSlotChain'] // SessionProvider seat arrives with the session-scope child declaration; // ChatView never invokes it (render-prop pass-through stub). const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children(SID)} @@ -144,6 +146,7 @@ function makeHarness(init?: Partial) { useStore: bindSnapshotSelector(chat), actions: chat.actions, renderSlot, + renderSlotChain, SessionProvider: SessionProviderStub, openDetails, openFile, @@ -733,7 +736,6 @@ describe('ChatView', () => { // not re-render, so the row's renderSlot call count freezes during chunks. let rowRenders = 0 h.props.renderSlot = ((key: string, _owner: object) => { - // The turnTail hole renders through the same share; only tool rows count here. if (key !== 'conversation.chat.toolview') return null rowRenders += 1 return
    diff --git a/packages/client/ui-deliverables/src/client/ProducedFiles.tsx b/packages/client/ui-deliverables/src/client/ProducedFiles.tsx index 609a688586..ab85869de2 100644 --- a/packages/client/ui-deliverables/src/client/ProducedFiles.tsx +++ b/packages/client/ui-deliverables/src/client/ProducedFiles.tsx @@ -1,14 +1,11 @@ // ProducedFiles: the produced-file row a finished turn ends with. The paths -// come from the mutation tools' follow-along locations (see -// producedForClosing), never from the closing prose, so the answer carries -// its own output whether or not the model remembered to name it. Clicking one -// goes through the same openFile the tool rows use — the Host's own opener, -// on the Host machine. +// come pre-matched by the turn-tail chain from the mutation tools' +// follow-along locations, never from the closing prose. Clicking one goes +// through the same openFile the tool rows use — the Host's own opener, on the +// Host machine. -import { useMemo } from 'react' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { producedForClosing } from './turn-deliverables.ts' import type { NS } from './locales.ts' import css from './ProducedFiles.module.css' @@ -21,21 +18,17 @@ function basename(path: string): string { return at === -1 ? path : path.slice(at + 1) } -/** Full props: the turn-tail owner currency plus this plugin's locale seat. */ -export type ProducedFilesProps = TurnTailOwnerProps & PropsLocale +/** Matched paths plus the opener and locale seats needed to present them. */ +export type ProducedFilesProps = Pick & { + matched: readonly string[] +} & PropsLocale /** * Render one turn's produced files as openable chips. - * @param props - the tail hole's owner currency (snapshot nodes, the closing - * assistant's seq, the chat view's file opener) and the locale seat. - * @returns The row, or `null` when the turn produced nothing. + * @param props - selector-matched paths, the chat view's file opener, and the locale seat. + * @returns The produced-files row. */ -export function ProducedFiles({ nodes, seq, openFile, t }: ProducedFilesProps) { - // Per-closing-message derivation over the windowed snapshot: O(nodes) on - // node-identity change only, which is the same cadence the owning view - // re-derives its own flow at. - const paths = useMemo(() => producedForClosing(nodes, seq), [nodes, seq]) - if (paths.length === 0) return null +export function ProducedFiles({ matched: paths, openFile, t }: ProducedFilesProps) { const shown = paths.slice(0, SHOWN) const hidden = paths.length - shown.length return ( diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index 536c019b01..6dc7bc4b84 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -10,6 +10,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import { ProducedFiles } from './ProducedFiles.tsx' import { en, NS, zh, type DeliverablesKey } from './locales.ts' +import { selectProducedFiles } from './turn-deliverables.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { @@ -34,8 +35,7 @@ export function apply(ctx: ClientContext): void { 'conversation.chat.turnTail', () => ctx.slots.register({ name: 'conversation.chat.turnTail', - id: 'produced-files', - order: 0, + select: selectProducedFiles, locale: NS, }, ProducedFiles), ) diff --git a/packages/client/ui-deliverables/src/client/turn-deliverables.ts b/packages/client/ui-deliverables/src/client/turn-deliverables.ts index faa0455b37..a3ddf40b59 100644 --- a/packages/client/ui-deliverables/src/client/turn-deliverables.ts +++ b/packages/client/ui-deliverables/src/client/turn-deliverables.ts @@ -4,6 +4,7 @@ * own follow-along `locations`, never the closing prose. */ import type { ConversationNode, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' +import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' /** * Paths a call view reports having created or changed, by render intent rather @@ -76,3 +77,13 @@ export function producedForClosing(nodes: readonly ConversationNode[], seq: numb } return [] } + +/** + * Claim the turn-tail chain only when its closing turn produced files. + * @param owner - Turn-tail owner currency for the closing assistant. + * @returns Produced paths as the component's match, or null to decline before mount. + */ +export function selectProducedFiles({ nodes, seq }: TurnTailOwnerProps): readonly string[] | null { + const paths = producedForClosing(nodes, seq) + return paths.length === 0 ? null : paths +} diff --git a/packages/client/ui-deliverables/tests/produced-files.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.spec.tsx index e5d92424a3..49e41ebd86 100644 --- a/packages/client/ui-deliverables/tests/produced-files.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.spec.tsx @@ -15,7 +15,7 @@ import type { import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { ProducedFiles } from '../src/client/ProducedFiles.tsx' -import { producedForClosing } from '../src/client/turn-deliverables.ts' +import { producedForClosing, selectProducedFiles } from '../src/client/turn-deliverables.ts' import { apply, inject } from '../src/client/index.ts' import { apply as applyNode } from '../src/index.ts' import { apply as applyInvariant } from '../src/invariant.ts' @@ -64,6 +64,8 @@ describe('producedForClosing derivation', () => { assistant(9, 'second turn', 2), ] expect(producedForClosing(nodes, 7)).toEqual(['out/index.html', 'out/app.css']) + expect(selectProducedFiles({ nodes, seq: 7, openFile: () => {} })).toEqual(['out/index.html', 'out/app.css']) + expect(selectProducedFiles({ nodes, seq: 9, openFile: () => {} })).toBeNull() // A turn that produced nothing yields the empty list, and so does an // anchor the window does not contain. expect(producedForClosing(nodes, 9)).toEqual([]) @@ -126,8 +128,7 @@ describe('ProducedFiles row', () => { // it shows and says so rather than dropping the rest silently. const paths = ['deep/a.html', 'b.css', 'c.ts', 'd.ts', 'e.ts', 'f.ts', 'g.ts'] const openFile = vi.fn<(path: string) => void>() - const nodes: ConversationNode[] = [user(1, 'build it'), wrote(2, 'w', ...paths), assistant(3, 'done', 1)] - const view = render() + const view = render() expect(view.getByText('产物')).toBeTruthy() // Chips carry the basename; the full path stays reachable as the title. const chip = view.getByRole('button', { name: '打开 deep/a.html' }) @@ -138,12 +139,6 @@ describe('ProducedFiles row', () => { fireEvent.click(chip) expect(openFile).toHaveBeenCalledWith('deep/a.html') }) - - it('a turn that produced nothing renders no row at all', () => { - const nodes: ConversationNode[] = [user(1, 'hi'), assistant(2, 'hello', 1)] - const view = render( {}} t={t} />) - expect(view.container.firstChild).toBeNull() - }) }) describe('package shells', () => { @@ -169,7 +164,7 @@ describe('plugin registration', () => { // The owning view's child declaration, stood up by a bench root entry. ctx.slots.register({ name: 'root', - children: { 'conversation.chat.turnTail': { kind: 'list', scope: 'session' } }, + children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } }, } as never, () => null) await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await() From daac50c84ce3637bffbdeac350cac0a6ef0beb76 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:19:26 +0800 Subject: [PATCH 052/104] fix(client): name turn-tail selector owner --- .../client/ui-deliverables/src/client/turn-deliverables.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/client/ui-deliverables/src/client/turn-deliverables.ts b/packages/client/ui-deliverables/src/client/turn-deliverables.ts index a3ddf40b59..c9754d1da4 100644 --- a/packages/client/ui-deliverables/src/client/turn-deliverables.ts +++ b/packages/client/ui-deliverables/src/client/turn-deliverables.ts @@ -83,7 +83,8 @@ export function producedForClosing(nodes: readonly ConversationNode[], seq: numb * @param owner - Turn-tail owner currency for the closing assistant. * @returns Produced paths as the component's match, or null to decline before mount. */ -export function selectProducedFiles({ nodes, seq }: TurnTailOwnerProps): readonly string[] | null { +export function selectProducedFiles(owner: TurnTailOwnerProps): readonly string[] | null { + const { nodes, seq } = owner const paths = producedForClosing(nodes, seq) return paths.length === 0 ? null : paths } From 2dc1406dfdd67b11fbfec1aca2f485f2cd6f71f6 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 7 Aug 2026 17:36:08 +0800 Subject: [PATCH 053/104] feat(ui-models): drop the provider-scoped reasoning effort, and red-flag a bad route id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Reasoning effort leaves the provider cards entirely.** It is a per-MODEL capability and the models under one provider disagree about which levels they accept: setting `anthropic` to `max` made six of its eight models throw UNSUPPORTED_REASONING_EFFORT, and because the catalog build catches per provider, the whole provider vanished from the picker behind one error row. A provider-scoped control can only ever be set to a value some of its models reject. The composer's model picker already offers each model its own levels, and a switch there now records provider, model, and effort together as the next session's default — so the setting has a better home at the right granularity. The profile field stays in `settings.yaml` for a deployment that knows its route; only the control is gone, from both cards and both adapter families. Two `components.spec` cases used the control as the vehicle for their op assertions and now use `baseURL`, which is what they were actually testing. **A rejected Provider ID now reads as a fault.** It shared the neutral hint paragraph with the field's guidance, so the copy telling the user what they got wrong looked like advice. Reuses the existing `.error` style, matching the split the key field already makes. --- apps/web/tests/models-settings.e2e.ts | 18 ++--- .../models.expected.md | 6 -- packages/client/ui-models/README.i18n.yaml | 4 +- packages/client/ui-models/README.md | 4 +- packages/client/ui-models/README.zh.md | 4 +- .../src/client/CustomProviderCard.tsx | 20 +++--- .../ui-models/src/client/ModelsSection.tsx | 4 -- .../ui-models/src/client/ProviderEditor.tsx | 39 +++------- .../src/client/ReasoningEffortField.tsx | 72 ------------------- .../client/ui-models/src/client/locales.ts | 4 -- .../ui-models/tests/components.spec.tsx | 24 +++---- .../ui-models/tests/provider-form.spec.tsx | 49 +++++++------ 12 files changed, 70 insertions(+), 178 deletions(-) delete mode 100644 packages/client/ui-models/src/client/ReasoningEffortField.tsx diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 6ebdbc1d3a..d539c391c0 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -159,23 +159,23 @@ describe('web e2e: Models settings page configures a dormant provider', () => { const dialog = page.getByRole('dialog', { name: '设置' }) await dialog.getByRole('button', { name: '编辑 minimax-cn' }).click() await dialog.getByText('自定义设置').click() - const effort = dialog.getByLabel('推理强度') - await effort.waitFor({ timeout: 10_000 }) - await effort.selectOption('high') + const url = dialog.getByLabel('API 地址') + await url.waitFor({ timeout: 10_000 }) + await url.fill('https://gateway.minimax.example/v1') await dialog.getByRole('button', { name: '保存', exact: true }).click() // The editor closes back to the row; the fold's write merged into the // stored profile beside the reference. - await expect.poll(async () => dialog.getByLabel('推理强度').count(), { timeout: 10_000 }).toBe(0) + await expect.poll(async () => dialog.getByLabel('API 地址').count(), { timeout: 10_000 }).toBe(0) await dialog.getByText('已保存 minimax-cn。', { exact: true }).waitFor({ timeout: 10_000 }) const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8') - expect(document).toContain('reasoning: high') + expect(document).toContain('baseURL: https://gateway.minimax.example/v1') expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY') const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) await compareOrRefreshGolden(CONFIGURED_EXPECTED, snapshot, MODE) expect(tripwire.pageErrors).toEqual([]) }, 60_000) - it('declares a route the adapter does not ship, without a reasoning control', async () => { + it('declares a route the adapter does not ship', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-models-declare')) const dialog = page.getByRole('dialog', { name: '设置' }) const declare = dialog.getByRole('button', { name: '添加自定义提供方' }) @@ -184,9 +184,9 @@ describe('web e2e: Models settings page configures a dormant provider', () => { await dialog.getByLabel('Provider ID').fill('acme-gateway') await dialog.getByLabel('显示名称').fill('Acme Gateway') await dialog.getByLabel('API 地址').fill('https://gateway.acme.example/v1') - // No reasoning effort anywhere for a hand-declared route: its models carry - // no reasoning capability, so a profile effort would make every model on - // the route fail to resolve and drop the provider out of the picker. + // No reasoning effort on a provider card at all: effort is a per-model + // capability, the models under one provider disagree about it, and a + // switch in the composer already records provider+model+effort together. expect(await dialog.getByLabel('推理强度').count()).toBe(0) await dialog.getByRole('button', { name: '添加模型' }).click() await dialog.getByLabel('模型 ID 1').fill('acme-large') diff --git a/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md index 45790a8f33..931caf0acb 100644 --- a/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md @@ -25,12 +25,6 @@ - text: 自定义设置 API 地址 - textbox "API 地址": - /placeholder: https://api.deepseek.com - - text: 推理强度 - - combobox "推理强度": - - option "默认" [selected] - - option "off" - - option "high" - - option "max" - region "模型目录": - text: 模型目录 已自定义模型目录 - button "恢复默认模型" diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index e2b9e45f9d..5d579d3b51 100644 --- a/packages/client/ui-models/README.i18n.yaml +++ b/packages/client/ui-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-models/README.md -README.md: dec43de43899ef99e74b1fd73ffb4bf3c4e97b3e -README.zh.md: c17eb611f071c4054d12d36acb2de8a94fa95a20 +README.md: cf4e50630339c4055e9ae2df37246b814af06966 +README.zh.md: 2b9158fa4419bf07f496fce47c938744f2a4233f diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index dec43de438..cf4e506303 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Models settings plugin: the provider configuration page and official-DeepSeek conditional onboarding step. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status. -Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and 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. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. A row labels API-key state with a green solid dot only when a literal key or referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), `reasoningEffort` (deepseek) or `reasoning` (pi-ai), and each adapter's model catalog. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its localized confirmation dialog names the provider in the title, description, and final action. A row is tagged **Custom** when the directory entry says the owning adapter ships nothing under that key. The tag follows that answer alone: having a stored profile does not make a route custom — narrowing a shipped provider's models stores one too — and an adapter that reports nothing leaves its rows untagged rather than being read as shipped. +Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and 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. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. A row labels API-key state with a green solid dot only when a literal key or referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint) and each adapter's model catalog. Reasoning effort is deliberately NOT among them: it is a per-model capability and the models under one provider disagree about which levels they accept, so a provider-scoped control could only be set to a value some of them reject — which took the whole provider out of the model picker. The composer's model picker offers each model its own levels, and a switch there records provider, model, and effort together as the default for the next session. The profile field stays in `settings.yaml` for a deployment that knows its route. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its localized confirmation dialog names the provider in the title, description, and final action. A row is tagged **Custom** when the directory entry says the owning adapter ships nothing under that key. The tag follows that answer alone: having a stored profile does not make a route custom — narrowing a shipped provider's models stores one too — and an adapter that reports nothing leaves its rows untagged rather than being read as shipped. The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface. @@ -16,7 +16,7 @@ A pi-ai profile's `models` list is edited on the card: one row per model showing **Fetch available models** asks `llm.discoverModels` about the endpoint the form **currently shows**, including a base URL edited but not yet saved and a key typed but not yet stored, so adding a provider is one pass instead of save-then-return. The reply opens a picker rather than being written: candidates already configured start unchecked, so adopting a selection never overwrites a capacity the user corrected. A provider that cannot be interrogated is a detour, not a dead end — the adapter's own message appears beside the rows, which stay editable by hand. -**Add a custom provider** declares a route pi-ai does not ship. It is its own card rather than the editor with extra fields, because the route id is being chosen here and the settings address does not exist until it is: one `settings.mutate` sets the whole profile at `providers.`, and the key travels separately through `credentials.set` under the same `_API_KEY` derivation an existing provider uses. What a hand-declared route cannot default gates the create button — a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model — so the failure names the field while the user is still looking at it. The id must start with a lowercase letter, because it is also the stem of the derived credential reference and a reference is a POSIX shell identifier: a digit-leading id otherwise passes every check this card makes and then fails at the credential seam with a raw regular expression. Capacities do not gate it: the adapter's fallbacks size a model the endpoint described by id alone, which is what most listings return. The protocol choices are read out of the namespace's own schema rather than a wire field or a constant, so they cannot drift from the ones the adapter accepts. The card records the conventional `apiKeyEnv` reference only when a key is typed, the same rule the editor applies, so a route declared for provider-native authentication is not born pointing at a reference nothing will ever set. When the profile write lands but the key write fails, the provider already exists: the card settles the fields describing it, retries the credential alone — re-running the profile write would carry the revision that write just superseded, so the Host would answer `settings-conflict` and the key could never be stored from here — and reports the created provider even if the user then cancels. Neither this card nor the editor offers a reasoning effort for such a route: a hand-declared model carries no reasoning capability — pi-ai's installed catalog is what supplies one, and it ships nothing under this route — so a profile effort makes `resolveModel` throw for every model on the route and drops the whole provider out of the picker. The editor withholds the control on the directory's `declared` bit for exactly that reason; a route the adapter ships keeps it. +**Add a custom provider** declares a route pi-ai does not ship. It is its own card rather than the editor with extra fields, because the route id is being chosen here and the settings address does not exist until it is: one `settings.mutate` sets the whole profile at `providers.`, and the key travels separately through `credentials.set` under the same `_API_KEY` derivation an existing provider uses. What a hand-declared route cannot default gates the create button — a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model — so the failure names the field while the user is still looking at it. The id must start with a lowercase letter, because it is also the stem of the derived credential reference and a reference is a POSIX shell identifier: a digit-leading id otherwise passes every check this card makes and then fails at the credential seam with a raw regular expression. Capacities do not gate it: the adapter's fallbacks size a model the endpoint described by id alone, which is what most listings return. The protocol choices are read out of the namespace's own schema rather than a wire field or a constant, so they cannot drift from the ones the adapter accepts. The card records the conventional `apiKeyEnv` reference only when a key is typed, the same rule the editor applies, so a route declared for provider-native authentication is not born pointing at a reference nothing will ever set. When the profile write lands but the key write fails, the provider already exists: the card settles the fields describing it, retries the credential alone — re-running the profile write would carry the revision that write just superseded, so the Host would answer `settings-conflict` and the key could never be stored from here — and reports the created provider even if the user then cancels. ## Model Experience diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index c17eb611f0..2b9158fa44 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -4,7 +4,7 @@ 模型设置插件:提供方配置页和按条件显示的 DeepSeek 官方首次使用引导步骤。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。 -行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可以询问提供方它服务什么。只有确认字面密钥或引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另有 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai),以及各适配器自己的模型目录。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。当目录条目表明拥有该路由的适配器在这个键下什么都没有时,该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器,其路由保持无标签,不会被当成内置。 +行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可以询问提供方它服务什么。只有确认字面密钥或引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),以及各适配器自己的模型目录。推理等级刻意**不在**其中:它是按模型的能力,而同一提供方下各模型接受的档位并不一致,因此提供方级的控件只可能被设成其中一些模型会拒绝的值——那会让整个提供方从模型选择器里消失。输入框的模型选择器为每个模型提供它自己的档位,在那里切换会把提供方、模型、推理等级一并记为下一个会话的默认值。profile 字段仍留在 `settings.yaml`,供清楚自己路由的部署使用。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。当目录条目表明拥有该路由的适配器在这个键下什么都没有时,该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器,其路由保持无标签,不会被当成内置。 前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。 @@ -16,7 +16,7 @@ pi-ai profile 的 `models` 列表就在卡片上编辑:一行一个模型, **获取可用模型**会针对表单**当前显示**的端点调用 `llm.discoverModels`,包括已修改但尚未保存的 API 地址和已键入但尚未存储的密钥,因此新增一个提供方是一趟走完,而不是「先保存再回来」。回复会打开一个选择框而不是直接写入:已配置过的候选默认不勾选,因此采纳一次选择绝不会覆盖用户已更正的容量。无法被询问的提供方只是绕路而非死路——适配器自己的消息会显示在各行旁边,而这些行仍可手工编辑。 -**添加自定义提供方**用来声明 pi-ai 未提供的路由。它是独立的一张卡片而非在编辑器上加字段,因为路由 id 正是在这里被*选定*的,而在选定之前 settings 地址并不存在:一次 `settings.mutate` 在 `providers.` 上设置整个 profile,密钥则经 `credentials.set` 单独传递,使用与既有提供方相同的 `_API_KEY` 派生。手工声明的路由无法默认的东西会门控创建按钮——唯一的 **Provider ID**、端点、协议,以及至少一个由唯一标识的模型——因此失败会在用户仍看着该字段时点名它。该 id 必须以小写字母开头,因为它同时是派生凭据引用的词干,而引用是 POSIX shell 标识符:数字开头的 id 否则会通过这张卡片的每一项检查,然后在凭据 seam 上以一条用户无从下手的原始正则失败。容量不参与门控:端点只按 id 描述的模型(这正是多数列表返回的形态)由适配器的回退值定尺寸。协议选项读自该 namespace 自己的 schema,而非某个协议字段或常量,因此它们不会与适配器实际接受的集合发生漂移。只有键入了密钥,这张卡片才记录约定的 `apiKeyEnv` 引用,与编辑器同一条规则,因此一条为提供方原生认证声明的路由不会一出生就指向一个永远不会被设置的引用。当 profile 写入成功而密钥写入失败时,提供方其实已经存在:卡片会把描述它的字段定住,只重试凭据——再跑一次 profile 写入会带着刚被自己这次写入取代的 revision,宿主将以 `settings-conflict` 应答,密钥就再也无法从这里存下——并且即使用户随后取消,也照实报告提供方已创建。这类路由在两张卡片上都不提供推理等级:手工声明的模型没有推理能力——能力来自 pi-ai 的已安装 catalog,而它在这条路由下什么都没有——因此 profile 级等级会让该路由上每个模型的 `resolveModel` 抛错,整个提供方从选择器里消失。编辑器正是依据目录的 `declared` 位收起这个控件;适配器自带的路由则保留它。 +**添加自定义提供方**用来声明 pi-ai 未提供的路由。它是独立的一张卡片而非在编辑器上加字段,因为路由 id 正是在这里被*选定*的,而在选定之前 settings 地址并不存在:一次 `settings.mutate` 在 `providers.` 上设置整个 profile,密钥则经 `credentials.set` 单独传递,使用与既有提供方相同的 `_API_KEY` 派生。手工声明的路由无法默认的东西会门控创建按钮——唯一的 **Provider ID**、端点、协议,以及至少一个由唯一标识的模型——因此失败会在用户仍看着该字段时点名它。该 id 必须以小写字母开头,因为它同时是派生凭据引用的词干,而引用是 POSIX shell 标识符:数字开头的 id 否则会通过这张卡片的每一项检查,然后在凭据 seam 上以一条用户无从下手的原始正则失败。容量不参与门控:端点只按 id 描述的模型(这正是多数列表返回的形态)由适配器的回退值定尺寸。协议选项读自该 namespace 自己的 schema,而非某个协议字段或常量,因此它们不会与适配器实际接受的集合发生漂移。只有键入了密钥,这张卡片才记录约定的 `apiKeyEnv` 引用,与编辑器同一条规则,因此一条为提供方原生认证声明的路由不会一出生就指向一个永远不会被设置的引用。当 profile 写入成功而密钥写入失败时,提供方其实已经存在:卡片会把描述它的字段定住,只重试凭据——再跑一次 profile 写入会带着刚被自己这次写入取代的 revision,宿主将以 `settings-conflict` 应答,密钥就再也无法从这里存下——并且即使用户随后取消,也照实报告提供方已创建。 ## 模型体验 diff --git a/packages/client/ui-models/src/client/CustomProviderCard.tsx b/packages/client/ui-models/src/client/CustomProviderCard.tsx index e27bd3c6bd..f055b325a5 100644 --- a/packages/client/ui-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-models/src/client/CustomProviderCard.tsx @@ -14,13 +14,11 @@ * and at least one model — are required here rather than at load, so the * failure names the field while the user is still looking at it. * - * There is deliberately no reasoning-effort control. A hand-declared model - * carries no reasoning capability — pi-ai's installed catalog is what supplies - * one, and it has nothing under this route — so a profile effort here makes - * `resolveModel` throw UNSUPPORTED_REASONING_EFFORT for every model on the - * route, which drops the whole provider out of the model picker. The editor - * card hides the control for the same reason once the directory reports the - * route as declared. + * There is deliberately no reasoning-effort control, here or on the editor + * card: effort is a per-MODEL capability, and the models under one provider + * disagree about it, so a provider-scoped control can only be set to a value + * some of them reject. The composer's model picker offers each model its own + * levels instead. */ import { useState } from 'react' @@ -206,9 +204,11 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { onChange={(event) => { setRoute(event.target.value) }} />
    -

    - {routeInvalid ? t('customRouteInvalid') : routeTaken ? t('customRouteTaken') : t('customRouteHint')} -

    + {/* A rejected id reads as a fault, not as guidance — the same split the + key field below already makes between its failure and its hint. */} + {routeInvalid || routeTaken + ?

    {t(routeInvalid ? 'customRouteInvalid' : 'customRouteTaken')}

    + :

    {t('customRouteHint')}

    }
    {t('customDisplayName')} ) @@ -140,7 +137,6 @@ function targetOf(row: ProviderRow): EditorTarget { settingsNs: row.entry.settingsNs, settingsPath: row.entry.settingsPath, ...credentialRef === undefined ? {} : { credentialRef }, - ...row.entry.declared === undefined ? {} : { declared: row.entry.declared }, } } diff --git a/packages/client/ui-models/src/client/ProviderEditor.tsx b/packages/client/ui-models/src/client/ProviderEditor.tsx index 3e6b26658f..ff23e35b63 100644 --- a/packages/client/ui-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-models/src/client/ProviderEditor.tsx @@ -7,10 +7,12 @@ * a key is entered; a blank key materializes a reference-free profile for * provider-native authentication); * the collapsed 自定义设置 area carries the per-family extras (`baseURL` for - * both families, `reasoningEffort` for deepseek / `reasoning` for pi-ai — - * withheld for a hand-declared route, whose models have no reasoning - * capability to configure — and DeepSeek's id/name/context-window model - * catalog). Everything else stays + * both families and DeepSeek's id/name/context-window model catalog). + * Reasoning effort is deliberately absent: it is a per-MODEL capability, and + * the models under one provider disagree about it, so a provider-scoped + * control can only be set to a value some of them reject. The composer's + * model picker offers each model its own levels; `settings.yaml` keeps the + * profile field for a deployment that knows its route. Everything else stays * owned by `settings.yaml`. Profile edits land as minimal `settings.mutate` * path ops against the stored section — the card reads the redacted * descriptor, so it names only the fields it can see and a stored literal @@ -29,14 +31,12 @@ import { import { apiKeyFailure } from './apiKey.ts' import { EditorFooter } from './EditorFooter.tsx' import { ModelListEditor } from './ModelListEditor.tsx' -import { EFFORT_FIELD, ReasoningEffortField } from './ReasoningEffortField.tsx' -import type { EffortFamily } from './ReasoningEffortField.tsx' import { deriveKeyRef, messageOf } from './store.ts' import type { en } from './locales.ts' import styles from './ModelsSection.module.css' /** Per-adapter-family curated field sets (unknown namespaces get the hint alone). */ -type EditorLayout = EffortFamily | 'unknown' +type EditorLayout = 'deepseek' | 'pi-ai' | 'unknown' /** The public DeepSeek endpoint shown as the deepseek base-URL placeholder. */ const DEEPSEEK_PUBLIC_BASE_URL = 'https://api.deepseek.com' @@ -57,13 +57,6 @@ export interface ProviderEditorProps { api: Pick /** Section copy. */ t: (key: keyof typeof en) => string - /** - * Whether the owning adapter knows this route only because configuration - * declared it. Such a route's models carry no reasoning capability, so the - * effort control is withheld; absent means the adapter draws no such - * distinction and the control shows. - */ - declared?: boolean /** Disable writes (read-only settings provider). */ readOnly: boolean /** Close the editor; `changed` reports whether an Apply committed. */ @@ -303,8 +296,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { * family as a parameter is what makes `EFFORT_FIELD` total here: an * unknown namespace never reaches this body. */ - const curatedFields = (family: EffortFamily): ReactNode => { - const effortField = EFFORT_FIELD[family] + const curatedFields = (family: 'deepseek' | 'pi-ai'): ReactNode => { const customModels = getPath(draft, ['models']) const modelsOverridden = hasPath(draft, ['models']) const models = modelDrafts(modelsOverridden ? customModels : inheritedModels()) @@ -361,21 +353,6 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { }} />
    - {/* A hand-declared route's models carry no reasoning capability - (pi-ai's installed catalog is what supplies one, and it has - nothing under such a route), so a profile effort would make - `resolveModel` throw for every model on it and drop the whole - provider out of the picker. Offering the control at all would - be offering a way to break the route. */} - {props.declared === true ? null : ( - { setField(effortField, effort) }} - t={t} - disabled={disabled} - /> - )} {/* Both families edit the same rows through the same contract; only the extras differ — DeepSeek's inherited capacities, pi-ai's endpoint interrogation. */} diff --git a/packages/client/ui-models/src/client/ReasoningEffortField.tsx b/packages/client/ui-models/src/client/ReasoningEffortField.tsx deleted file mode 100644 index a129637135..0000000000 --- a/packages/client/ui-models/src/client/ReasoningEffortField.tsx +++ /dev/null @@ -1,72 +0,0 @@ -/** - * The provider-level reasoning-effort select: the profile's own default - * effort, applied to every model on the route unless a request names one. The - * empty option means "inherit", which on the wire is the field being absent - * rather than an empty string. - * - * It carries the per-family vocabulary and field name so the editor's two - * layouts cannot spell them differently. Only routes the adapter ships get - * this control at all — a hand-declared model has no reasoning capability to - * configure, and a profile effort over one makes its whole route fail to - * resolve — so the create card renders nothing here by construction. - */ - -import type { ReactNode } from 'react' -import type { en } from './locales.ts' -import styles from './ModelsSection.module.css' - -/** The adapter families that expose a provider-level effort, and their vocabularies. */ -export type EffortFamily = 'deepseek' | 'pi-ai' - -/** Reasoning vocabularies per family; the empty option means "inherit". */ -export const EFFORT_CHOICES: Record = { - deepseek: ['off', 'high', 'max'], - 'pi-ai': ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'], -} - -/** The profile key each family's effort lives under. */ -export const EFFORT_FIELD: Record = { - deepseek: 'reasoningEffort', - 'pi-ai': 'reasoning', -} - -/** Props of {@link ReasoningEffortField}. */ -export interface ReasoningEffortFieldProps { - /** Which vocabulary to offer. */ - family: EffortFamily - /** Current value; the empty string is the inherit option. */ - value: string - /** Receives the chosen effort, or undefined for inherit. */ - onChange: (effort: string | undefined) => void - /** Section copy. */ - t: (key: keyof typeof en) => string - /** Disable the control (busy or read-only). */ - disabled: boolean -} - -/** - * Render the provider-level reasoning-effort select. - * @param props - family vocabulary, current value, change sink, copy, and disabled state. - * @returns the labelled select. - */ -export function ReasoningEffortField( - { family, value, onChange, t, disabled }: ReasoningEffortFieldProps, -): ReactNode { - return ( -
    - {t('effort')} - -
    - ) -} diff --git a/packages/client/ui-models/src/client/locales.ts b/packages/client/ui-models/src/client/locales.ts index 9809c61283..7d75e1de11 100644 --- a/packages/client/ui-models/src/client/locales.ts +++ b/packages/client/ui-models/src/client/locales.ts @@ -35,8 +35,6 @@ export const en = { customized: 'Customized settings', baseUrl: 'Base URL', baseUrlDefault: 'Provider default', - effort: 'Reasoning effort', - effortInherit: 'Default', models: 'Models', modelsInherited: 'Using the adapter defaults', modelsCustomized: 'Customized model catalog', @@ -130,8 +128,6 @@ export const zh: typeof en = { customized: '自定义设置', baseUrl: 'API 地址', baseUrlDefault: '提供方默认', - effort: '推理强度', - effortInherit: '默认', models: '模型目录', modelsInherited: '正在使用适配器默认模型', modelsCustomized: '已自定义模型目录', diff --git a/packages/client/ui-models/tests/components.spec.tsx b/packages/client/ui-models/tests/components.spec.tsx index 931410fb35..88eab4b998 100644 --- a/packages/client/ui-models/tests/components.spec.tsx +++ b/packages/client/ui-models/tests/components.spec.tsx @@ -92,13 +92,12 @@ function wireNamespaces(): SettingsNamespaceView[] { value: { apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base', - reasoningEffort: 'high', defaultContextWindow: 1_000_000, maxTokens: 256_000, models: DEFAULT_DEEPSEEK_MODELS, }, base: { defaultContextWindow: 1_000_000, maxTokens: 256_000, models: DEFAULT_DEEPSEEK_MODELS }, - user: { reasoningEffort: 'high' }, + user: { baseURL: 'https://base' }, applies: 'live', secrets: [{ path: ['apiKey'], set: false }], revision: 0, @@ -729,16 +728,16 @@ describe('ModelsSection', () => { // user layer and replaced it wholesale, deleting any stored literal key. const { replace, update, mutate } = await mountSection() fireEvent.click(screen.getByText(en.customized)) - const effort = screen.getByLabelText(en.effort) - expect(effort.value).toBe('high') - fireEvent.change(effort, { target: { value: '' } }) + const url = screen.getByLabelText(en.baseUrl) + expect(url.value).toBe('https://base') + fireEvent.change(url, { target: { value: '' } }) fireEvent.click(screen.getByText(en.apply)) await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) expect(replace).not.toHaveBeenCalled() expect(update).not.toHaveBeenCalled() expect(mutate.mock.calls[0]?.[0]).toEqual({ ns: 'llm-deepseek', - ops: [{ op: 'unset', path: ['reasoningEffort'] }], + ops: [{ op: 'unset', path: ['baseURL'] }], expectedRevision: 0, }) }) @@ -795,17 +794,16 @@ describe('ModelsSection', () => { const urls = screen.getAllByLabelText(en.baseUrl) expect(urls).toHaveLength(2) expect((urls[1] as HTMLInputElement).value).toBe('https://proxy') - const effort = screen.getAllByLabelText(en.effort) - fireEvent.change(effort[effort.length - 1] as HTMLSelectElement, { target: { value: 'xhigh' } }) + fireEvent.change(urls[1] as HTMLInputElement, { target: { value: 'https://proxy/v2' } }) fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement) await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) - // Only the edited field travels: apiKeyEnv, baseURL and headers were - // already stored with these values, so no op restates them — and the - // profile's stored literal apiKey, absent from the redacted view the card - // read, is named by nothing at all. + // Only the edited field travels: apiKeyEnv and headers were already stored + // with these values, so no op restates them — and the profile's stored + // literal apiKey, absent from the redacted view the card read, is named by + // nothing at all. expect(mutate.mock.calls[0]?.[0]).toEqual({ ns: 'llm-pi-ai', - ops: [{ op: 'set', path: ['providers', 'openai', 'reasoning'], value: 'xhigh' }], + ops: [{ op: 'set', path: ['providers', 'openai', 'baseURL'], value: 'https://proxy/v2' }], expectedRevision: 0, }) }) diff --git a/packages/client/ui-models/tests/provider-form.spec.tsx b/packages/client/ui-models/tests/provider-form.spec.tsx index 7d8f2efe27..7e5ef5f36e 100644 --- a/packages/client/ui-models/tests/provider-form.spec.tsx +++ b/packages/client/ui-models/tests/provider-form.spec.tsx @@ -705,35 +705,24 @@ describe('hand-declared providers', () => { expect(set).toHaveBeenCalledWith({ ref: 'ACME_GATEWAY_API_KEY', value: 'gw-key' }) }) - it('offers no reasoning effort at all, in either card, for a hand-declared route', async () => { + it('scopes each card to fields a provider can actually own', async () => { + // Reasoning effort used to sit here. It is a per-MODEL capability and the + // models under one provider disagree about it, so a provider-scoped + // control could only be set to a value some of them reject — which took + // the whole provider out of the picker. The composer's model picker owns + // the choice, and a switch there records provider+model+effort together. + const fields = () => [...document.querySelectorAll('input,select')] + .map(el => el.getAttribute('aria-label')).filter(Boolean) + mountCard() fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } }) - fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } }) - // A hand-declared model carries no reasoning capability — pi-ai's - // installed catalog is what supplies one, and it ships nothing under this - // route — so a profile effort makes `resolveModel` throw - // UNSUPPORTED_REASONING_EFFORT for every model on it and drops the whole - // provider out of the picker. Offering the control would be offering a way - // to break the route. - expect(screen.queryByLabelText(en.effort)).toBeNull() + expect(fields()).toEqual([en.customRoute, en.customDisplayName, en.baseUrl, en.customApi, en.keyInput]) cleanup() - // The editor card withholds it for the same route for the same reason... - await mountSection({ - providers: { 'acme-gateway': { apiKeyEnv: 'ACME_GATEWAY_API_KEY', baseURL: 'https://acme.test/v1' } }, - declaredRoutes: ['acme-gateway'], - }) - openEditor('acme-gateway') - expect(screen.queryByLabelText(en.effort)).toBeNull() - cleanup() - - // ...and keeps it for a route the adapter actually ships, whose models do - // carry the capability. await mountSection({ providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY' } } }) openEditor('openai') - const select = screen.getByLabelText(en.effort) - expect([...select.options].map(option => option.value)) - .toEqual(['', 'off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']) + fireEvent.click(screen.getByText(en.customized)) + expect(fields()).toEqual([en.keyInput, en.baseUrl]) }) it('retries only the key after the profile landed, and reports the provider on cancel', async () => { @@ -827,6 +816,20 @@ describe('hand-declared providers', () => { expect(screen.queryByText(en.customRouteInvalid)).toBeNull() }) + it('styles a rejected route id as a fault and its guidance as a hint', () => { + mountCard() + const routeField = screen.getByLabelText(en.customRoute) + // Same split the key field makes: what the user got wrong reads as a + // fault, what they have yet to do reads as guidance. + expect(screen.getByText(en.customRouteHint).className).toMatch(/advancedHint/) + + fireEvent.change(routeField, { target: { value: '2' } }) + expect(screen.getByText(en.customRouteInvalid).className).toMatch(/error/) + + fireEvent.change(routeField, { target: { value: 'openai' } }) + expect(screen.getByText(en.customRouteTaken).className).toMatch(/error/) + }) + it('derives a reference the credential seam accepts for every id it admits', () => { // The two rules have to stay in step; this is the relation, checked // directly rather than through the DOM. From f3049e5663c74c9a33ea4934049ec5438d2e259f Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 7 Aug 2026 18:07:15 +0800 Subject: [PATCH 054/104] fix(llm-pi-ai): describing a model must not fail on a bad profile level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resolveModel` validated the profile's reasoning level against the exact model and threw when it did not fit. That call builds the model catalog, and the catalog build catches per PROVIDER — so one mis-set field took the whole provider out of every picker behind a single error row, hiding even the models that do support the level. Measured: `anthropic` set to `max` threw for six of its eight models. Describing what a model can do now reports an unusable profile level as no default rather than throwing; the request path still refuses it, which is where a bad configuration belongs. The existing spec asserted the old throw and now asserts both halves of that split. Known gap, left deliberately: a model that cannot take the route's level still fails its first request while the picker shows 「Default」 for it, because the request path keeps using the profile level as the fallback. Reaching that needs a hand-written `settings.yaml` — the Models page no longer writes the field — and the error names the model and the level, so selecting a supported level is a way out. Closing it properly means giving `AgentOptions` a `reasoningEffort` so compositions without a model picker keep an entry point, then dropping the provider-scoped field altogether; that is its own change. --- packages/llm/llm-pi-ai/README.i18n.yaml | 4 ++-- packages/llm/llm-pi-ai/README.md | 2 +- packages/llm/llm-pi-ai/README.zh.md | 2 +- packages/llm/llm-pi-ai/src/adapter.ts | 25 +++++++++++++++++++- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 15 ++++++++++-- 5 files changed, 41 insertions(+), 7 deletions(-) diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 6ea82781de..b57043a84d 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/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/llm/llm-pi-ai/README.md -README.md: a8686aa6f26095a9dd40c447aa0d0f61f7bc5412 -README.zh.md: 8f190097b543fe1d0324daa37a162cdc91d3e2dc +README.md: 97bd629adedda9d63fee730bc31129b0c22cc704 +README.zh.md: 71d45b590f48f4b8162ae329b58b5ff4a9eb13b1 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index a8686aa6f2..97bd629ade 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -73,7 +73,7 @@ The adapter exposes each configured route's models through `ctx.llm.listModels(p A model that carries reasoning metadata exposes pi-ai's ordered `getSupportedThinkingLevels(model)` result without filtering or normalization, including `off` and the model-specific availability of `xhigh` or `max`. The Harness exposes each canonical pi-ai level as an opaque ID; provider/model wire spellings remain inside pi-ai's `thinkingLevelMap`. -A model **without** that metadata — every hand-declared one, and a catalog model pi-ai marks as non-reasoning — exposes no `reasoning` at all. pi-ai reports such a model as supporting the single level `off`, but `off` is translated to *omitting* the reasoning option, which is byte-for-byte the request that naming no effort already produces: selecting it could not disable anything, so a provider whose own default is to think would keep thinking with `off` shown as selected. Reporting the capability as unavailable leaves a surface offering the provider's default and nothing that misrepresents it. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and any explicit value absent from the exact model capability fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. pi-ai's common stream options represent `off` by omitting `reasoning`. +A model **without** that metadata — every hand-declared one, and a catalog model pi-ai marks as non-reasoning — exposes no `reasoning` at all. pi-ai reports such a model as supporting the single level `off`, but `off` is translated to *omitting* the reasoning option, which is byte-for-byte the request that naming no effort already produces: selecting it could not disable anything, so a provider whose own default is to think would keep thinking with `off` shown as selected. Reporting the capability as unavailable leaves a surface offering the provider's default and nothing that misrepresents it. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and a level absent from the exact model capability fails the REQUEST with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. Describing a model never fails that way: the models under one provider disagree about which levels they accept, so `resolveModel` reports a profile level the exact model cannot take as no default at all rather than throwing. A throw there would take the whole provider out of every model catalog built over it — one mis-set profile field hiding even the models that do support the level — so a bad configuration surfaces where it is acted on, not where it is described. pi-ai's common stream options represent `off` by omitting `reasoning`. Supported profile fields are `apiKey`, `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `defaultContextWindow`, `defaultMaxTokens`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. Each profile's optional retry policy is captured with that provider route; omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 8f190097b5..71d45b590f 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -73,7 +73,7 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩 携带推理元数据的模型会公开 pi-ai 有序的 `getSupportedThinkingLevels(model)` 结果,不经筛选或规范化,其中包括 `off`,以及模型对 `xhigh` 或 `max` 的特定支持。Harness 将每个规范 pi-ai 级别公开为不透明 ID;提供方/模型在协议格式中的表示仍保留在 pi-ai 的 `thinkingLevelMap` 中。 -**没有**这份元数据的模型——每一个手工声明的模型,以及 pi-ai 标记为不具备推理能力的 catalog 模型——完全不公开 `reasoning`。pi-ai 会把这类模型报告为只支持 `off` 一档,但 `off` 会被翻译成*省略* reasoning 选项,而那与「不点名任何档位」产出的请求逐字节相同:选它关不掉任何东西,于是自身默认就在思考的提供方,会在界面显示 `off` 被选中的同时继续思考。把该能力报告为不可用,界面就只剩提供方默认这一项,不会再出现自相矛盾的控件。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;任何未出现在确切模型能力中的显式值都会在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。 +**没有**这份元数据的模型——每一个手工声明的模型,以及 pi-ai 标记为不具备推理能力的 catalog 模型——完全不公开 `reasoning`。pi-ai 会把这类模型报告为只支持 `off` 一档,但 `off` 会被翻译成*省略* reasoning 选项,而那与「不点名任何档位」产出的请求逐字节相同:选它关不掉任何东西,于是自身默认就在思考的提供方,会在界面显示 `off` 被选中的同时继续思考。把该能力报告为不可用,界面就只剩提供方默认这一项,不会再出现自相矛盾的控件。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;未出现在确切模型能力中的档位会让**请求**在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。**描述**一个模型则从不这样失败:同一提供方下各模型接受的档位并不一致,因此 `resolveModel` 对该模型拿不下的 profile 档位报告为「没有默认值」,而不是抛错。在那里抛错会让整个提供方从任何基于它构建的模型目录中消失——一个配错的 profile 字段连支持该档位的模型也一并藏起来——所以坏配置暴露在被执行处,而不是被描述处。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。 受支持的 profile 字段是 `apiKey`、`apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`defaultContextWindow`、`defaultMaxTokens`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs` 和 `retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准。 diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 365c3901a5..e974cdff7c 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -94,6 +94,29 @@ function profileOptions( } } +/** + * The profile default this exact model can actually take, for DESCRIBING it. + * A configured level the model does not support yields none rather than + * throwing: `resolveModel` builds the model catalog, and a catalog that fails + * takes its whole provider out of every picker — so one mis-set profile field + * would hide every model on the route, including the ones that support the + * level. The request path still refuses, which is where a bad configuration + * belongs: describing what a model can do must not fail because a deployment + * asked it for something it cannot. + * @param model - the resolved model descriptor. + * @param effort - the profile's configured level, if any. + * @returns the level when this model supports it, otherwise undefined. + */ +function describableReasoningLevel( + model: Model, + effort: ReasoningEffortIdType | ModelThinkingLevel | undefined, +): ModelThinkingLevel | undefined { + if (effort === undefined) return undefined + return getSupportedThinkingLevels(model).some(level => level === effort) + ? effort as ModelThinkingLevel + : undefined +} + /** Validate an explicit Harness/profile effort without invoking pi-ai's clamp. */ function resolveReasoningLevel( model: Model, @@ -229,7 +252,7 @@ export class PiAiAdapter extends LlmAdapter { const snapshot = this.current() const profile = this.profileOf(snapshot, provider) const resolvedModel = this.modelOf(snapshot, provider, model) - const defaultLevel = resolveReasoningLevel(resolvedModel, profile.reasoning) + const defaultLevel = describableReasoningLevel(resolvedModel, profile.reasoning) // Only a cap the deployment configured is a request default; the // catalog's `maxTokens` sizes the model and stops there. const configuredMaxTokens = profile.configuredMaxTokens.get(model) diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index a9c4335a92..0184ca05cc 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -372,13 +372,24 @@ describe('provider profile lifecycle', () => { await expect(supported.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash')) .resolves.toMatchObject({ reasoning: { defaultEffort: ReasoningEffortId('max') } }) + // A profile level this model cannot take DESCRIBES as no default rather + // than failing: resolveModelInfo builds the model catalog, and a catalog + // that throws takes its whole provider out of every picker — one mis-set + // field would hide every model on the route, including the ones that do + // support the level. The request path below is where it is refused. const unsupported = new Context() await unsupported.plugin(LlmService) await unsupported.plugin(LlmPiAi, { providers: { deepseek: { reasoning: 'medium' } }, }) - await expect(unsupported.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash')) - .rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' }) + const described = await unsupported.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash') + expect(described.reasoning?.defaultEffort).toBeUndefined() + expect(described.reasoning?.efforts.length).toBeGreaterThan(0) + await expect(assemble(unsupported, { + provider: 'deepseek', model: 'deepseek-v4-flash', messages: [], + })).resolves.toMatchObject({ + finish: { kind: 'error', failure: { code: 'UNSUPPORTED_REASONING_EFFORT' } }, + }) const disabled = new Context() await disabled.plugin(LlmService) From b1074e60ab64f7a99e21ab3cd0bb655d62a9f3c1 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 7 Aug 2026 18:54:50 +0800 Subject: [PATCH 055/104] test(web): re-record the skill-tool-row golden for the resolved seat label Master added this scenario while this branch was open, so its golden froze the composer seat's "Select model" fallback. The scaffold's route-only adapter (added here for fixture-less scenarios) makes the seat resolve the model those scenarios actually route to, which is what the other eight goldens on this branch already show. Only the two seat lines move. --- apps/web/tests/snapshots/skill-tool-row/ui.expected.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/tests/snapshots/skill-tool-row/ui.expected.md b/apps/web/tests/snapshots/skill-tool-row/ui.expected.md index fc1f23d484..15ddf45a0d 100644 --- a/apps/web/tests/snapshots/skill-tool-row/ui.expected.md +++ b/apps/web/tests/snapshots/skill-tool-row/ui.expected.md @@ -38,8 +38,8 @@ - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write -- button "Select model": - - text: Select model +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] - text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 280 tok · Output 30 tok From 2da1309836e36320cd2a6818e576feaeb01f5558 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:46:43 +0800 Subject: [PATCH 056/104] fix: npm publish for profile --- scripts/publish-npm-baseline.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/scripts/publish-npm-baseline.ts b/scripts/publish-npm-baseline.ts index 92df711f3e..20b9eeaea0 100644 --- a/scripts/publish-npm-baseline.ts +++ b/scripts/publish-npm-baseline.ts @@ -463,13 +463,6 @@ class InstalledBundleSmoke { + `expected ${this.bundle.manifest.version}`, ) } - const config = this.runner.capture( - process.execPath, - [bin, '--dump-default-config'], - consumerRoot, - environment, - ) - if (config === '') throw new Error('installed dsh --dump-default-config returned no output') this.probeWeb(bin, consumerRoot, environment) console.log('publish-npm-baseline: installed dsh entry and Web startup probes passed') } finally { From effd8e1ebd5b2146759d80c81bfa8f27b1cfcb3a Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:13:52 +0800 Subject: [PATCH 057/104] docs: add TypeRT remote gateway RFC --- ...08-02-typert-remote-method-calls.i18n.yaml | 6 + .../2026-08-02-typert-remote-method-calls.md | 489 ++++++++++++++++++ ...026-08-02-typert-remote-method-calls.zh.md | 489 ++++++++++++++++++ 3 files changed, 984 insertions(+) create mode 100644 .agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml create mode 100644 .agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.md create mode 100644 .agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.zh.md diff --git a/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml new file mode 100644 index 0000000000..cc2f0736d4 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.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-02-typert-remote-method-calls.md +2026-08-02-typert-remote-method-calls.md: c3a7a77c583720c3f967de185a089d374f017d81 +2026-08-02-typert-remote-method-calls.zh.md: 9b2fbbd69f1c054cbf6c86f177b743c583be3e8a diff --git a/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.md new file mode 100644 index 0000000000..c3a7a77c58 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.md @@ -0,0 +1,489 @@ +# Agent Note: TypeRT Gateway Targeted Method Calls + +Status: proposed + +English | [中文](2026-08-02-typert-remote-method-calls.zh.md) + +## Problem + +The Host API Proxy handles direct method calls, stateful interactions, and Session event streams. These concerns have different lifecycles, routing semantics, and client programming interfaces. Continuing to export all business operations through one package would couple business Services, transport protocols, state machines, and client types. + +This proposal addresses only targeted method calls in which one request produces one result. Stateful interactions such as Permission and Approval, as well as Session event streams, do not use this design and will be designed separately. + +The contract for a direct method call belongs to the business Service that implements it. Business developers should declare only which methods are remotely callable, without also maintaining a central API interface, routing table, parameter conversion table, client stub, and Zod schema. + +The Host and Browser Client use separate TypeScript Programs because each side augments the Cordis `Context` type differently. A Remote projection must not import the complete Host declarations into a consumer or depend on Browser-specific types. If the TUI later reuses this programming interface, it must likewise see only methods marked Remote. TUI integration is outside the current scope, but the implementation boundary must preserve this isomorphic reuse. + +## Proposal + +A business Service declares callable methods with `@Remote` or `@RemoteContext()` and explicitly joins the Gateway through `bindTypeRTGateway()`. TypeRT generates the Host-local reflection artifact and a platform-independent Remote consumer projection from the Host Program. The Client Program continues to generate its own local reflection artifact independently. + +The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. The `.d.ts` exposes only methods marked with a Remote decorator and refers to the business package's single public type symbols. The `.d.ts.map` navigates consumer API methods back to their Host business method implementations. The `.js` carries endpoint, parameter, Context, and Zod information for the same contract. At the assembly layer, the Browser Client mounts the required Remote JS contributions onto the Client API Service. The projection and API abstraction remain platform-independent so that a future TUI can reuse them. + +`@deepseek-ai/dsh-host-api-gateway`, located at `packages/host/api-gateway`, provides two symmetric faces: its default entry provides Host `ctx.typertGateway`, while its `/client` entry provides consumer-side `ctx.api`. Each side consumes a locally generated `InvocationDescriptor` from the same model; descriptors are not sent over the wire. The Remote data protocol runs over the single Connection/RPC mechanism through an isolated `/api2` channel. The business calling interface does not change when Connection migrates from HTTP to WebSocket. + +## Components and Cordis services + +| Component | Cordis service | Responsibility in this proposal | +|---|---|---| +| `@deepseek-ai/dsh-type-meta` | Declares only the minimal `ctx.typert` protocol | Decorators, bindings, descriptors, lookup/Context, and the Remote map; no dependency on the compiler, Zod, Connection, or Browser | +| TypeRT registry | `ctx.typert` | Separately stores reflection for the current environment, imported Remote contributions, lookup providers, and Context providers | +| TypeRT generator/loader | No new business service | Generates three kinds of `lib` artifacts from the Host/Client Programs and registers the current environment's artifacts with `ctx.typert` | +| Host API Gateway's Host face | `ctx.typertGateway` | Associates Host definitions with live Services, decodes parameters, resolves receivers, invokes methods, and encodes results | +| Connection | `ctx.connection` | Exclusively owns the HTTP Server/future WebSocket, RPC envelope, rpcId, serialization, trust, and error transport, while carrying the isolated `/api` and `/api2` channels | +| Host API Gateway's Client face | `ctx.api` | Mounts Remote contributions, materializes root and scoped APIs, and delegates canonical calls to `ctx.connection.rpc` | +| Client Remotes | No new service | Serves as the only Remote facade for Client business code, selecting and mounting `/remote` contributions while exposing the Gateway Client face and the selected API declarations | +| Agent/Session owning packages | Existing domain services | Provide both static interface merges and runtime lookup/Context providers | +| Business packages such as Goal | Existing business Services | Declare only bindings, Remote methods, and canonical DTOs, and export the generated `/remote` subpath | + +The Host Gateway does not depend on concrete implementations of `ctx.agents`, `ctx.sessions`, `ctx.goals`, or `ctx.httpServer`. The Client API does not understand the physical carrier, and Connection does not understand Goal, Agent, lookup, `InvocationDescriptor`, or Client API namespaces. + +## Business declarations + +Ordinary direct calls use `@Remote`. When migrating to an existing Service or Registry, do not rename or alter existing methods. Add `remoteExport*` entry points at the end of the class and use decorator arguments to declare their short API names. A method explicitly declares every required business object in a top-level parameter position: + +```text +export class GoalService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'goals') + + create(agent: Agent, request: CreateGoalRequest): CreateGoalResult { + // Existing business method remains unchanged. + } + + @Remote('create') + remoteExportCreate(agent: Agent, request: CreateGoalRequest): CreateGoalResult { + return this.create(agent, request) + } +} +``` + +`goals` is an explicit Cordis service key and is the default wire namespace. Override it through an option to `bindTypeRTGateway()` only when the protocol namespace genuinely needs to differ from the service key. + +Use `@RemoteContext()` when the Service receiver must be resolved within an isolated kind of Context. Context identity does not enter the business method's parameters: + +```text +export class ScopedGoalService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'goals') + + @RemoteContext('agent', 'create') + remoteExportCreate(request: CreateGoalRequest): Promise { + // Runs against the goals service resolved from the Agent Context. + } +} +``` + +An endpoint selects exactly one invocation mode. A flow that needs an explicit `Agent` parameter uses `@Remote`. A flow that first switches to an Agent Context and then resolves a scoped receiver uses `@RemoteContext('agent')`. TypeRT does not infer either mode from the method body or from a missing parameter. + +Business packages depend only on the lightweight `@deepseek-ai/dsh-type-meta`. It provides declaration protocols for decorators, `bindTypeRTGateway()`, lookup, Remote Context, and descriptors, without depending on the TypeScript compiler, Zod, HTTP, or the Client runtime. + +## Decorators and the explicit Gateway facet + +A decorator only states that a method participates in the Remote contract. It performs no runtime type reflection and injects no hidden symbol into a Service constructor. The arguments to `@Remote('create')` and `@RemoteContext('agent', 'create')` are external method names, while the actual member remains named `remoteExportCreate`. The member name becomes the external method name only when no alias is provided. `typertGateway` is the sole explicit marker that a Service has joined the Gateway, making this capability visible on both the business class and its runtime instance. + +In SRC mode, the decorator may record the prototype, method name, and invocation mode in a `WeakMap` internal to `dsh-type-meta`. It writes no custom properties to a Service instance, prototype, constructor, or method function. + +In LIB mode, the TypeRT compiler performs strict method discovery, type resolution, and descriptor generation. Generation neither rewrites business source nor secretly supplies generated arguments to `bindTypeRTGateway()`. + +## Lookup and Remote Context registration + +The Gateway has no built-in branches for Agent, Session, or other business objects. Each object-owning package provides both a static declaration and a runtime provider: + +```text +declare module '@deepseek-ai/dsh-type-meta' { + interface TypeRTLookupMap { + agent: TypeRTLookup + } +} + +ctx.typert.lookups.register('agent', { + parameter: 'agent', + wire: 'agentId', + resolve: sessionId => resolveAgent(sessionId), +}) +``` + +The static declaration tells TypeRT that `Agent` corresponds to `SessionId` on the wire. The runtime provider resolves an `agentId` in a request to the currently live `Agent` object. If either side is missing, the LIB build or the earliest resolvable runtime registration fails immediately. + +Lookup objects such as Agent and Session may each occupy only one top-level parameter position. An ordinary JSON request may be passed as another complete parameter, but this proposal does not support `request.agent`, object destructuring, arrays of objects, nested lookups, or searching arbitrary complex structures for IDs. + +Remote Context uses a separate merge-extensible map and provider. The Agent package registers an `agent` Context provider that locates the Agent Context from its wire identity and resolves the Service key named by the descriptor from that Context. The Gateway does not know the internal structure of an Agent Context. + +The Client also registers an `agent` Context binder. The binder only retrieves a `SessionId` from the Context in which a call occurs; it neither enumerates Scopes nor copies methods into each one. A Cordis Service tracker automatically rebinds a scoped namespace to the current Agent Context. + +## InvocationDescriptor + +TypeRT, the permissive SRC parser, Host Gateway, and Client API exchange one canonical description: + +```text +InvocationDescriptor { + id: '@deepseek-ai/dsh-goal#goals/create' + service: 'goals' + namespace: 'goals' + method: 'create' + implementation: 'remoteExportCreate' + invocation: direct | { context: 'agent', wire: 'agentId' } + scope?: { context: 'agent', wire: 'agentId' } + parameters: [ + { name, wire, source: json | lookup, lookup?, codec } + ] + result: codec + sourceLocation +} +``` + +`method` is the external short name used by the endpoint and Client API; `implementation` is the actual member name on the Host receiver. `implementation` may be omitted when the two names match. A `direct` descriptor retains the original Service instance as the receiver. A Context descriptor first uses the corresponding Context provider to find the scoped Context, then resolves the receiver by the descriptor's service key. + +The strict generator writes `scope` only when a direct method has exactly one lookup parameter, a `TypeRTContextMap` declaration with the same name exists, and both use the same wire type symbol. `scope.wire` must identify that lookup parameter. It declares that a consumer may fill this parameter from the Context in which the call occurs, without changing the Host receiver or endpoint. No scoped projection is generated when there are multiple lookups, no Context declaration, or mismatched wire types; a type mismatch is a build error. + +Parameter order comes from the method signature. HTTP fields come from parameter names or lookup declarations. The Gateway does not infer optional fields, Context types, lookup types, or missing arguments from request contents, and it does not synthesize business defaults. + +A LIB codec contains a Zod schema and a canonical `typeSymbol` consisting of "package + public subpath + export name." An SRC codec is marked only as `src-json`. When the Host and consumer run in different JavaScript realms, each holds its own Zod instances, but both sets are generated from the same TypeRT model and symbol keys. + +Descriptors exist only in the local registry on each side. The wire carries only the `/api2` channel, endpoint, and `{ args }` payload. The Host uses its descriptor to decode and invoke the method, while the Client uses its corresponding descriptor to encode arguments and validate the result. + +## TypeRT runtime registry + +```text +ctx.typert.local 当前进程自己的 Host 或 Client reflection +ctx.typert.remotes 消费端显式 mount 的对端 Remote contribution +ctx.typert.lookups wire ID 到 Host 活对象的 provider +ctx.typert.contexts Host Context resolver 与 Client Context binder +``` + +Every registration returns a disposer owned by the caller's Cordis fiber. The Gateway and API Service read the current snapshot before subscribing to changes, so business Services, generated contributions, providers, and consumers can load in any order. When any dependency is disposed, its related endpoints or methods become unavailable immediately. + +The registry's Host root entry has the complete `TypeRTService` interface merge. The registry implementation shared by Host and Client lives in a separate module without environment declarations. The registry's `/client` entry imports only that shared implementation and does not pass through the Host root entry, so it cannot bring Host Cordis declarations into the Client Program. + +## Canonical types, symbols, and Zod + +Remote Client DTS does not copy business DTOs or redeclare structurally identical shadow types. It imports original symbols only from public, type-only subpaths that do not carry Host Cordis merges: + +```text +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { CreateGoalRequest, CreateGoalResult } from '@deepseek-ai/dsh-goal/types' +``` + +Consequently, `SessionId`, the Agent wire ID, the request, and the result all refer to the same TypeScript declaration in the Host and Browser Client. A future TUI can reuse them without a second set of types. Go to Definition, renames, and Find References for a DTO return to the one source location for the business type instead of stopping at a copy in a generated file. + +Remote API methods themselves use declaration-map navigation. TypeRT anchors `InvocationModel.location` to the method-name token of the Host `remoteExport*` method and emits a source-map segment on the corresponding property of the namespace interface. After the TypeScript editor resolves `ctx.api.models.list` to its generated declaration, `typert.remote-client.d.ts.map` takes it to the Host Service's `remoteExportList` entry point. That entry point explicitly calls the existing, unrenamed `list()` method; the map does not misidentify the decorator, class, or full signature as the method definition. + +TypeRT generates a wire Zod codec for the same symbol key. The Host Gateway uses it to validate input and encode results, while the Client API may use it to encode arguments and validate responses. If a complex type cannot produce a strict codec, the LIB build fails instead of degrading to `unknown` or unchecked JSON. + +Named business types referenced by Remote methods must be exported from public, type-only subpaths. If the only reachable entry also imports Host Services, Cordis `Context` merges, or Host-only implementations, the build fails and requires the business package to provide a safe type entry. Primitives, literals, and simple compositions explicitly supported by TypeRT need no additional names. + +A lookup parameter does not expose the `Agent` class to consumers. The Remote projection refers to the canonical ID type in the lookup declaration, such as `SessionId`, while the Host continues to resolve objects through the canonical `Agent` class symbol. + +## Three artifact kinds and two TypeScript Programs + +The Host and Client still use only two independent TypeScript Programs, but TypeRT generates three semantically distinct kinds of artifacts: + +```text +Host Program +├─ typert.host.js / typert.host.d.ts +│ Host 自身的 Service、Event、Object、schema 和 inbound Gateway 信息 +└─ typert.remote-client.js / typert.remote-client.d.ts / typert.remote-client.d.ts.map + Host Remote 对任意消费环境的 wire 投影 + +Client Program +└─ typert.client.js / typert.client.d.ts + Client 自身的 Service、Event、Object 和 schema 信息 +``` + +`remote-client` is the Host Program's second emitter, not a third Program or the Client's local face. It contains no Host Cordis merge, Service class, Context class, or implementation code, and it does not enter the Host-local reflection registry. + +The Host lib build performs strict Host analysis and emits both the Host-local and Remote consumer artifacts. The Client lib then consumes the Remote DTS. The complete order is: + +```text +Host lib build +→ 生成 typert.host.{js,d.ts} +→ 生成各业务包 lib/typert.remote-client.{js,d.ts,d.ts.map} +→ 完成 Client lib 和 typert.client 产物 +→ Vite 构建 Web +``` + +The existing top-level `build` still runs `build:lib` before `build:web`, but `build:lib` must complete the Host and Remote artifacts before starting Client TypeScript compilation. A clean build must not depend on stale `.d.ts` files from an earlier build. + +## The `/remote` package entry + +Every business package that provides Remote methods exports a generated `/remote` subpath: + +```text +"./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" +} +``` + +Consumer code selects a capability through the business package itself: + +```text +import goalsRemote from '@deepseek-ai/dsh-goal/remote' +``` + +This import brings the `.d.ts` map augmentation into the current TypeScript project while supplying the JS descriptor for the same contract as a value to the runtime. A business package that is not imported does not extend the current project's Remote API types. + +The business package's published files must include both `lib/typert.remote-client.d.ts.map` and the `src` file referenced by that map. The generated DTS refers to its adjacent map with `//# sourceMappingURL=typert.remote-client.d.ts.map`; the map source points from `lib` to the business source by a relative path such as `../src/index.ts`. The `/remote` export does not list the map separately; the package `files` field publishes it together with the source. + +Code that needs only static types may use `import type {} from '@deepseek-ai/dsh-goal/remote'`. This import is erased at runtime, loads no JS, and cannot trigger runtime registration. An environment that makes real calls must pass the contribution from a normal value import to the API Service. + +Workspace resolution for `/remote` must explicitly target generated `lib` artifacts and must not let a general package-to-`src` paths rule redirect it to Host source. Ordinary business imports may continue resolving to SRC or LIB according to each environment's existing rules. + +## Strict consumer API types + +Remote DTS extends the flat endpoint map, direct namespace interface, namespace map, and scoped map without augmenting the global Cordis `Context`: + +```text +interface TypeRTRemoteNamespace$676f616c73 { + create: ( + agentId: SessionId, + request: CreateGoalRequest, + ) => Promise +} + +interface TypeRTRemoteMap { + 'goals/create': ( + agentId: SessionId, + request: CreateGoalRequest, + ) => Promise +} + +interface TypeRTRemoteNamespaceMap { + goals: TypeRTRemoteNamespace$676f616c73 +} + +interface TypeRTRemoteContextMap { + 'agent:goals/create': ( + request: CreateGoalRequest, + ) => Promise +} +``` + +`TypeRTRemoteMap` preserves canonical endpoint signatures for protocol typing and reflection. The root API type reads `TypeRTRemoteNamespaceMap` directly instead of deriving methods indirectly through a key-remapped mapped type; the TypeScript Language Service cannot reliably navigate such indirect properties through a declaration map. A namespace interface name encodes the namespace's UTF-8 bytes as hexadecimal, so `goals` deterministically becomes `TypeRTRemoteNamespace$676f616c73`. Different packages generate the same interface name for the same namespace and use module augmentation to merge their methods, while `TypeRTRemoteNamespaceMap.goals` always refers to that one type. + +TypeRT projects `TypeRTRemoteContextMap` onto a dedicated Scope type according to its Context key. The final programming interface remains: + +```text +api.goals.create(agentId, request) +agent.goals.create(request) +``` + +The Agent Scope supplies its own `SessionId` automatically. A `@Remote` method with an `agent` lookup can therefore generate both root and scoped consumer signatures. A `@RemoteContext('agent')` method also omits a separate Context identity, but generates only the scoped signature. In this phase, only the Client Agent Context gains `goals`; the Root Context does not. A future TUI must preserve the same Scope restriction. + +`RemoteApi` remains platform-independent, and the Browser Client uses it as its `ClientApi`. If a future TUI reuses this type, it must likewise access it through a dedicated API object and Agent Scope rather than treating the Host `Context` as a broader Service collection. Public Service methods without Remote markers do not enter the Remote maps. + +## Client TypeRT and the API Gateway Client face + +TypeRT in a consumer environment maintains both local information and Remote information imported from other environments, but stores them in separate registries: + +```text +TypeRT.local 当前环境自己的反射模型 +TypeRT.remotes 已导入的 Remote contribution +``` + +`@deepseek-ai/dsh-client-remotes/client` centrally loads the required Remote contributions: + +```text +import goalsRemote from '@deepseek-ai/dsh-goal/remote' +import sessionsRemote from '@deepseek-ai/dsh-session/remote' + +ctx.api.mount(goalsRemote) +ctx.api.mount(sessionsRemote) +``` + +Client business packages depend only on `@deepseek-ai/dsh-client-remotes/client`, not directly on the Host API Gateway or the runtime entry of each business `/remote`. Client Remotes itself depends on the Gateway Client face and re-exports declarations so the selected Remote map reaches business compilation. Adding or removing a complete Client capability changes only this assembly point. + +`ctx.api.mount()` registers a contribution with `TypeRT.remotes`, and its disposer is owned by the Cordis fiber that called the method. Duplicate endpoints, conflicting invocation modes for the same namespace and method, or conflicts between a descriptor and an existing type identity fail immediately. + +The API Service materializes each `@Remote` descriptor as a real function on the root `api`. The function constructs named `args` in descriptor parameter order, applies the Client's strict codec, and then calls `ctx.connection.rpc.call('/api2', endpoint, { args })`. + +Neither a direct descriptor with `scope` nor a `@RemoteContext` descriptor copies functions into every Agent Scope. The API Service creates one root singleton Cordis Service for each scoped namespace and materializes methods on that Service. When `agent.goals.create()` is called, the Cordis tracker rebinds the Service's `this.ctx` to the current Agent Context. The method then asks the corresponding Context binder for identity from `this.ctx`. A direct scoped projection substitutes this identity at the lookup position named by `scope.wire`; a Context descriptor writes the identity into the receiver's separate wire field. Both issue the same kind of `/api2` call. + +```text +root ctx.api.goals.create(agentId, request) + → direct descriptor + → ctx.connection.rpc.call('/api2', 'goals/create', { args }) + +agent.goals.create(request) + → tracker 将 namespace Service rebind 到 agent Context + → agent binder 从 caller Context 取得 agentId + → 用 agentId 补入同一 direct descriptor 的 lookup 参数 + → ctx.connection.rpc.call('/api2', 'goals/create', { args }) +``` + +The Root `Context` does not merge the scoped `goals` type; only `AgentContext` gains that property through `RemoteContextApi<'agent'>`. If a caller bypasses the type system and dynamically calls a scoped method from Root, the binder reports an explicit error. If the Client already has a Cordis service with the same name, or two contributions claim the same namespace and method incompatibly, mounting fails instead of overwriting the existing service. + +Generated Remote JS contains only descriptors, symbol keys, and codecs; it does not bundle Host Service implementations. The API Service can create real functions from that data, so this proposal does not depend on a JavaScript Proxy. A Proxy remains an implementation option but is not a source of types or reflection. + +## Cross-environment isomorphism constraints + +Remote API is a consumer capability, not a synonym for Browser API. This phase implements only Browser Client contribution mounting, Connection RPC calls, and Agent Scope association. + +Remote DTS, Remote JS, `RemoteApi`, `InvocationDescriptor`, the Remote RPC data protocol, and Context binders must not depend on the DOM, Browser module loaders, or HTTP. Through Connection, the Browser Client encodes descriptor-materialized methods as `/api2` RPC calls. + +A future TUI can join the same call abstraction without changing business decorators, Remote maps, or the shape of API calls. The TUI-visible API must still be generated exclusively from `@Remote` and `@RemoteContext`; sharing a process with the Host must not allow it to bypass Remote restrictions and expose Service methods directly. + +TUI runtime mounting, carriers, Agent Scope association, and SRC startup wiring are outside this phase. + +The Web already depends on build artifacts such as `lib/client.js`, so it requires a complete `build:lib` before startup. After the Host Remote contract changes, developers must rebuild the lib and then start or restart the Web. The first phase does not implement incremental watching of the Remote contract. + +## SRC and LIB operating modes + +SRC supports local source startup. The `WeakMap` records created by `@Remote` and `@RemoteContext()` provide method names and invocation modes. At runtime, the system reads ordered parameter names from the JavaScript function signature and combines them with registered lookup/Context providers to produce a permissive descriptor. + +For example, `@Remote('create') remoteExportCreate(agent, request)` resolves to the external method `create`, implementation member `remoteExportCreate`, and two top-level parameters. Lookup registration rewrites `agent` to the wire field `agentId`, while `request` is passed as a same-named JSON parameter. SRC does not start a `ts.Program`, use a preload or loader hook, generate or rewrite source, or inspect the internal structure of an ordinary JSON object. + +A signature that SRC cannot resolve unambiguously fails when the Service mounts. It does not guess at object destructuring, ambiguity caused by default parameters, rest parameters, nested lookups, or complex types. + +LIB supports CI, releases, and the prerequisite Web build. TypeRT scans the complete Host project and checks Remote decorators, explicit bindings, service keys, endpoint conflicts, lookup/Context declarations, public-symbol reachability, JSON codecs, and result codecs, then generates strict descriptors. + +At runtime, LIB only loads definitions from `lib`; it does not start the TypeScript compiler. The subsequent association of Services, lookup, Context resolution, invocation, and response encoding in the Host Gateway does not depend on whether a descriptor came from permissive SRC parsing or strict LIB generation. + +CI and releases use LIB. Moving all repository coverage to LIB is separate follow-up work and does not block this direct-method-call implementation. + +## Host Gateway registration + +The Host Gateway observes both TypeRT Remote definitions and the Cordis Service lifecycle. When a Service carrying the `typertGateway` facet and a definition with the same service key are both available, the Gateway registers the definition's endpoints. Their arrival order does not matter. + +At startup, the Gateway reads the current snapshots of TypeRT definitions and the Cordis reflection store before subscribing to registry changes and `internal/service`. It reconciles definitions, live Services, and bindings by service key, and unregisters endpoints when a Service is replaced or disposed. If a definition, lookup provider, or Context provider is removed, dependent endpoints immediately become unavailable; the Gateway neither retains invalid objects nor degrades to invoking methods with raw IDs. + +An ordinary `@Remote` call retains the original Service instance as receiver. After lookups succeed, the Gateway calls the member identified by `implementation ?? method` with parameters in descriptor order. + +A `@RemoteContext('agent')` call first asks the Agent Context provider to resolve the wire identity, then reads the descriptor's service key from that Context and invokes the scoped receiver. The business method receives neither a hidden Context parameter nor an Agent ID. + +```text +ctx.typertGateway.invoke({ namespace, method, args }) +→ 查找本地 InvocationDescriptor 与 live receiver +→ 按参数 descriptor 读取具名 wire 字段 +→ codec 解码普通值或 lookup ID +→ lookup provider 把 ID 解析为活对象 +→ direct 使用原 Service;context 先解析 scoped Context 和 Service +→ Reflect.apply(receiver[implementation ?? method], receiver, orderedArgs) +→ result codec 编码业务结果 +``` + +`ctx.typertGateway.invoke()` is the carrier-independent Host entry point. It neither creates an rpcId, RPC envelope, nor HTTP response. It returns only the encoded result or raises a Gateway error that the Connection RPC adapter maps for transport. + +## The `/api2` call chain + +`/api2` is an isolated protocol channel on the single Connection/RPC mechanism, not a transport created by the Gateway. The Gateway registers one local handler with Connection. This phase adds the following general channel capability to the existing HTTP Connection: + +```text +ctx.connection.rpc.handle('/api2', (endpoint, payload) => { + const { namespace, method } = parseEndpoint(endpoint) + const { args } = parsePayload(payload) + return ctx.typertGateway.invoke({ namespace, method, args }) +}) +``` + +The Connection Host half obtains a handle from the single HTTP Server and reuses the same RPC bridge, request/response envelope, rpcId, serialization, trust, transport errors, and `RpcError`. Its current physical mapping is: + +```text +POST /api2// +``` + +The Remote payload is a named JSON object, not a positional array, and does not carry an `InvocationDescriptor`. A normal Goal call has this payload slot: + +```json +{ + "args": { + "agentId": "session-1", + "request": { + "objective": "finish the migration" + } + } +} +``` + +The complete path is: + +```text +ctx.api.goals.create(sessionId, request) +→ Client InvocationDescriptor 编码 { args: { agentId, request } } +→ ctx.connection.rpc.call('/api2', 'goals/create', { args }) +→ Connection 创建 rpcId 和既有 client-request envelope +→ 当前 carrier 发送 POST /api2/goals/create +→ Connection Host half 执行 trust、反序列化和 RPC 分发 +→ /api2 handler 调用 ctx.typertGateway.invoke(...) +→ Host InvocationDescriptor 解码、lookup、receiver 解析和 Reflect.apply +→ result codec 编码 +→ Connection 写入既有 RPC result 并回送相同 rpcId +→ Client result codec 验证并返回 CreateGoalResult +``` + +Remote does not define a second-layer `{ ok, value/error }` response. Successful values and Gateway errors use the existing RPC response's `result` directly. The Gateway adapter maps endpoint, schema, lookup, Context, Service, and business-invocation failures to `RpcError`; Connection transports that error. + +The Gateway does not handle per-method permissions, caller identity, cancellation, idempotency, or long-lived connection state. This work only extends Connection with general channel registration and invocation capabilities. It does not change existing `/api`, trusted connection, trusted-host, or privileged-method semantics. Connection's WebSocket migration remains separate follow-up work. + +## Connection and protocol boundaries + +The API Service owns Remote contributions, method materialization, Scope binding, and the correspondence between positional parameters and descriptors. The Gateway owns Host descriptors, lookup, Context, and business invocation. Connection only sends `/api2`, the endpoint, and `{ args }` as one RPC call to the target and returns the existing RPC result; it does not understand Goal, Agent, lookup, descriptors, or Client API types. + +`/api` and `/api2` share one Connection, Server, RPC envelope, and connection lifecycle while remaining separate protocols. When Connection migrates from HTTP to WebSocket, `/api2` naturally changes from a physical path to a logical channel. The Remote payload, business decorators, generated DTS, Remote API types, and Agent Scope programming interface remain unchanged. + +## Package boundaries + +- `@deepseek-ai/dsh-type-meta`: lightweight protocols for decorators, bindings, lookup, Remote Context, and descriptors. +- TypeRT generator: analyzes Host/Client Programs, generates local faces and Remote consumer projections, and emits canonical symbol/Zod information. +- TypeRT runtime: separately stores the current environment's local reflection and imported Remote contributions. +- `@deepseek-ai/dsh-host-api-gateway`: its default entry associates Host definitions with Services, performs lookup, resolves Context receivers, invokes methods, encodes results, and registers an `/api2` handler with Connection; its `/client` entry mounts Remote contributions, creates strict API methods, and delegates calls to `ctx.connection.rpc`. The entries share the Remote protocol but do not import each other's Cordis interface merges. +- `@deepseek-ai/dsh-client-remotes`: the only Remote facade depended on by Client business code; directly depends on the Gateway Client face, selects `/remote` contributions, and exposes the merged API types to business packages. +- Connection: owns the single HTTP Server/future WebSocket carrier, RPC envelope, rpcId, serialization, trust, and error transport while carrying the isolated `/api` and `/api2` channels. +- Business-object packages such as Agent/Session: own lookup, Context providers, canonical ID types, and public type-only entries. +- Business Service packages: declare bindings, Remote methods, and their request/result types, and export the generated `/remote` subpath. + +## Initial implementation scope + +The first vertical path implements `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api2 → Host Gateway → GoalService.remoteExportCreate()` and proves that the same direct descriptor with an Agent lookup supports both `ctx.api.goals.create(agentId, request)` and `agentCtx.goals.create(request)`. The scoped-receiver semantics of `@RemoteContext('agent')` remain a separate mode. + +This phase implements Connection's general second-channel API and its current HTTP carrier mapping, but not WebSocket migration, the TUI runtime, a TUI carrier, or TUI Agent Scope wiring. This RFC also does not design Permission/Approval state machines, Session event streams, call authorization, cancellation, retries, idempotency, or cross-version protocol compatibility. + +## Alternatives considered + +**Continue using the central API Proxy package.** This would require business methods, Host routes, and Client interfaces to be declared repeatedly in several locations. It would also keep direct calls, stateful interactions, and event streams tied to the same lifecycle, so this alternative is rejected. + +**Perform strict reflection through decorators at runtime.** JavaScript decorators cannot recover erased TypeScript types, public symbol identity, or complete Zod codecs. Injecting a compiler-private symbol into a constructor would also hide the business class's real dependencies, so TypeRT generates strict information at compile time. + +**Use a preload, loader hook, or complete `ts.Program` during SRC startup.** This could reuse LIB analysis but would add requirements to every source startup entry. SRC needs only a usable permissive descriptor, so it uses decorator markers, function parameter names, and explicit providers; strict checks remain in the LIB contract pass. + +**Hand-write the Client interface.** A hand-written interface cannot guarantee that it contains only Remote-marked methods and can drift from Host signatures, lookup IDs, and Zod schemas. Client types are therefore projected automatically from the Host Program. + +**Use a TypeScript language-service/compiler plugin to make the Client understand decorators directly.** This would require editors, Vite, tsc, tsx, and published consumers to install an additional plugin, making integration too invasive. The design instead generates ordinary `.d.ts` files and standard declaration maps. + +**Import complete Host DTS into the Client or TUI.** This would pull in Host Services and Cordis interface merges while exposing unmarked methods to consumers. Remote DTS refers only to public, type-only symbols and augments dedicated Remote maps. + +**Generate only Remote DTS, without JS.** Types would work, but the runtime could not enumerate endpoints, codecs, and Context modes without a Proxy or another hand-written registry. The same Host projection therefore emits a Remote JS contribution as well. + +**Let a top-level `/remote` import register global state implicitly.** The target Cordis Context may not exist when ESM evaluation occurs, and ownership becomes ambiguous across multiple Contexts, HMR, and disposal. A normal value import therefore returns only a contribution, which the environment assembly explicitly mounts through the API Service. + +**Create a separate transport, HTTP route, and response envelope for Remote.** This would duplicate the existing Connection's Server ownership, rpcId, serialization, trust, errors, and future WebSocket lifecycle, requiring two RPC stacks to migrate separately. `/api2` instead reuses the single Connection/RPC mechanism as an isolated protocol channel. + +## Acceptance criteria + +- Goal Service retains its existing business method and adds a remote entry point at the end of the class through an explicit `typertGateway` and `@Remote('create') remoteExportCreate(...)`, without maintaining a second route, codec, or Client method list. +- One clean `build:lib` generates the Host Remote contract before compiling Host and Client consumers and produces JS, DTS, and a DTS map under the business package's `lib`, importable through `/remote`. +- After importing `@deepseek-ai/dsh-goal/remote`, a consumer project gets a strict `api.goals.create(...)` type; without the import, that namespace does not enter its types. Go to Definition on `create` follows the declaration map to the Host Service's `remoteExportCreate` implementation. +- After the Client assembly mounts the JS contribution obtained from the same import, TypeRT can reflect endpoint, parameter, result, lookup, Context, and Zod information, and the API Service creates the calling method without a hand-written stub. +- Remote DTS, Remote JS, `RemoteApi`, and the descriptor protocol do not depend on Browser-specific capabilities, and the type model cannot expose unmarked Goal Service methods, preserving the boundary required for future isomorphic TUI integration. +- `agent.goals.*` obtains its call Scope through the Cordis tracker and Context binder. The Root Context has no Agent-only type, and functions are not copied into each Scope. +- `/api2/goals/create` resolves `agentId` to the canonical Agent object, invokes the original Goal Service receiver, and returns the result through the existing RPC result/error mechanism. +- `/api2` and `/api` share the single Connection/RPC carrier while remaining protocol-isolated. Remote neither registers an HTTP Server handle directly nor defines a second response envelope. +- Connection provides general channel registration and invocation capabilities and maps `/api2` to the current HTTP carrier. Existing `/api` behavior and trust semantics remain unchanged. +- This implementation does not change existing `/api`, Connection/trusted connection, Permission/Approval, or Session event stream behavior. + +## Risks + +Remote API types depend on generated `lib` declarations. Build orchestration must finish the Host contract pass before compiling Host and Client consumers; an incorrect order makes a clean build depend on stale artifacts. + +Source navigation requires a Remote package to publish both its declaration map and the `src` file referenced by the map. If package `files` omits either side, types still compile but consumer navigation stops at the generated DTS. The workspace manifest check must therefore treat both as one publication contract. + +The permissive SRC descriptor does not validate the internal structure of ordinary JSON. After a Host Remote signature changes, the Web and strict type consumers must rebuild the lib; the first phase has no incremental contract watcher. + +Canonical public types require business DTOs to have type-only entries, which may expose packages whose Host types and implementation entries are currently mixed. The build rejects those boundaries instead of copying types to conceal them. + +Type imports and runtime contributions have different effects. `import type {}` extends only the static API. If a real calling environment omits the value contribution, the API Service must fail with an explicit "Remote not mounted" error. + +Browser and Host each hold their own Zod instances and cannot compare object identities across realms. Consistency is guaranteed only by canonical symbol keys, the same generated model, and wire behavior. + +A consumer may import a Remote contract that is not currently mounted on the Host. The types mean "this protocol capability was selected by the consumer," not that a corresponding Service currently exists in the target process; an unavailable endpoint must fail explicitly at runtime. + +Connection's general channel API must suit both the current HTTP carrier and a future WebSocket carrier. If the API exposes `fetch`, an HTTP request, or a route handle to the Gateway/API Service, WebSocket migration will pierce the Remote layer again. Those physical objects must therefore remain internal to Connection. diff --git a/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.zh.md new file mode 100644 index 0000000000..9b2fbbd69f --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -0,0 +1,489 @@ +# Agent Note: TypeRT Gateway 定向方法调用 + +Status: proposed + +[English](2026-08-02-typert-remote-method-calls.md) | 中文 + +## Problem + +Host API Proxy 同时承担直接方法调用、带状态交互和 Session 事件流。三者的生命周期、路由语义和客户端编程界面不同,继续共用一个业务导出包会让业务 Service、传输协议、状态机和客户端类型彼此耦合。 + +本方案只解决一次请求对应一次结果的定向方法调用。Permission、Approval 等带状态交互以及 Session 事件流不使用本方案,后续分别设计。 + +直接方法调用的契约属于实现该行为的业务 Service。业务开发者应只声明哪些方法可以远程调用,而不应再同步维护中央 API 接口、路由表、参数转换表、客户端 stub 和 Zod schema。 + +Host 与 Browser Client 使用独立的 TypeScript Program,因为两边会以不同类型合并同名 Cordis `Context`。Remote 投影不能把完整 Host 声明导入消费端,也不能依赖 Browser 专属类型;未来 TUI 若复用这套编程界面,也只能看到 Remote 标记的方法。本期不实现 TUI 接入,但实现边界不得阻断这种同构复用。 + +## Proposal + +业务 Service 通过 `@Remote` 或 `@RemoteContext()` 声明可调用方法,并通过 `bindTypeRTGateway()` 显式加入 Gateway。TypeRT 从 Host Program 生成 Host 本地反射产物和平台无关的 Remote 消费端投影;Client Program 继续独立生成自己的本地反射产物。 + +Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只暴露被 Remote decorator 标记的方法,并引用业务包唯一的公共类型符号;`.d.ts.map` 把消费端 API 方法导航回 Host 业务方法实现;`.js` 携带同一契约的 endpoint、参数、Context 和 Zod 信息。Browser Client 在 assembly 层把需要的 Remote JS 贡献集中挂到 Client API Service;该投影和 API 抽象保持平台无关,以便未来 TUI 复用。 + +`@deepseek-ai/dsh-host-api-gateway` 在 `packages/host/api-gateway` 内提供对称的两个 face:默认入口提供 Host `ctx.typertGateway`,`/client` 入口提供消费端 `ctx.api`。两边各自在本地消费由同一模型生成的 `InvocationDescriptor`,descriptor 不通过 wire 发送。Remote 数据协议运行在唯一 Connection/RPC 机制之上,使用独立 `/api2` channel;业务调用界面不随 Connection 从 HTTP 迁移到 WebSocket 而改变。 + +## 组件和 Cordis 服务 + +| 组件 | Cordis 服务 | 本方案中的职责 | +|---|---|---| +| `@deepseek-ai/dsh-type-meta` | 只声明 `ctx.typert` 的最小协议 | decorator、binding、descriptor、lookup/Context 和 Remote map;不依赖 compiler、Zod、Connection 或 Browser | +| TypeRT registry | `ctx.typert` | 分开保存当前环境 reflection、导入的 Remote contribution、lookup provider 和 Context provider | +| TypeRT generator/loader | 无新增业务服务 | 从 Host/Client Program 生成三类 `lib` 产物,并把当前环境产物注册到 `ctx.typert` | +| Host API Gateway 的 Host face | `ctx.typertGateway` | 关联 Host definition 与活 Service,解码参数、解析 receiver、调用方法和编码结果 | +| Connection | `ctx.connection` | 独占 HTTP Server/未来 WebSocket、RPC envelope、rpcId、序列化、trust 和错误传输,并承载 `/api` 与 `/api2` 两个隔离 channel | +| Host API Gateway 的 Client face | `ctx.api` | mount Remote contribution,实体化根 API 和 scoped API,把规范调用交给 `ctx.connection.rpc` | +| Client Remotes | 无新增服务 | 作为 Client 业务的唯一 Remote facade,选择并挂载 `/remote` contribution,同时传递 Gateway Client face 和所选 API 的类型声明 | +| Agent/Session owning 包 | 既有领域服务 | 同时提供静态 interface merge 与运行时 lookup/Context provider | +| Goal 等业务包 | 既有业务 Service | 只声明 binding、Remote 方法和唯一 DTO,并导出生成的 `/remote` 子路径 | + +Host Gateway 不依赖 `ctx.agents`、`ctx.sessions`、`ctx.goals` 或 `ctx.httpServer` 的具体实现。Client API 不理解物理 carrier,Connection 也不理解 Goal、Agent、lookup、`InvocationDescriptor` 或 Client API namespace。 + +## 业务声明 + +普通直接调用使用 `@Remote`。迁移到现存 Service 或 Registry 时不重命名、不改变存量方法;类末尾新增 `remoteExport*` 出口,并由 decorator 参数声明短 API 名。方法需要哪个业务对象,就在顶层参数位置显式声明该对象: + +```text +export class GoalService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'goals') + + create(agent: Agent, request: CreateGoalRequest): CreateGoalResult { + // Existing business method remains unchanged. + } + + @Remote('create') + remoteExportCreate(agent: Agent, request: CreateGoalRequest): CreateGoalResult { + return this.create(agent, request) + } +} +``` + +`goals` 是明确的 Cordis service key,并默认作为 wire namespace。只有协议 namespace 确实需要与 service key 不同时,才通过 `bindTypeRTGateway()` 的选项覆盖。 + +需要在某类隔离 Context 中查找 Service receiver 时使用 `@RemoteContext()`。Context identity 不进入业务方法参数: + +```text +export class ScopedGoalService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'goals') + + @RemoteContext('agent', 'create') + remoteExportCreate(request: CreateGoalRequest): Promise { + // Runs against the goals service resolved from the Agent Context. + } +} +``` + +同一个 endpoint 只能选择一种调用模式。需要显式 `Agent` 参数的流程使用 `@Remote`;需要切换到 Agent Context 再解析 scoped receiver 的流程使用 `@RemoteContext('agent')`,两者不会由 TypeRT 根据方法体或参数缺失自动猜测。 + +业务包只依赖轻量的 `@deepseek-ai/dsh-type-meta`。它提供 decorator、`bindTypeRTGateway()`、lookup、Remote Context 和 descriptor 的声明协议,不依赖 TypeScript compiler、Zod、HTTP 或 Client runtime。 + +## Decorator 与显式 Gateway facet + +Decorator 只表达“该方法参与 Remote 契约”,不负责运行时类型反射,也不向 Service constructor 注入隐藏 symbol。`@Remote('create')` 和 `@RemoteContext('agent', 'create')` 的参数是外部方法名,实际成员名保持 `remoteExportCreate`;未给别名时才使用成员名作为外部方法名。`typertGateway` 是 Service 加入 Gateway 的唯一显式标志,使业务类和运行时实例都能直接看出这项能力。 + +SRC 运行时允许 decorator 在 `dsh-type-meta` 内部的 `WeakMap` 记录 prototype、方法名和调用模式。它不向 Service 实例、prototype、constructor 或方法函数写入自定义属性。 + +LIB 的严格方法发现、类型解析和 descriptor 生成由 TypeRT compiler 完成。生成过程不改写业务源码,也不向 `bindTypeRTGateway()` 偷注生成参数。 + +## Lookup 与 Remote Context 注册 + +Gateway 不内置 Agent、Session 或其他业务对象分支。对象所属包同时提供静态声明和运行时 provider: + +```text +declare module '@deepseek-ai/dsh-type-meta' { + interface TypeRTLookupMap { + agent: TypeRTLookup + } +} + +ctx.typert.lookups.register('agent', { + parameter: 'agent', + wire: 'agentId', + resolve: sessionId => resolveAgent(sessionId), +}) +``` + +静态声明让 TypeRT 知道 `Agent` 在 wire 上对应 `SessionId`;运行时 provider 负责把请求中的 `agentId` 解析为当前活的 `Agent` 对象。缺少任一侧时,LIB 构建或最早可解析的运行时注册直接失败。 + +Agent、Session 等 lookup 对象只能各自占据一个顶层参数位置。普通 JSON request 可以作为另一个完整参数传入,但本方案不支持 `request.agent`、对象解构、对象数组、嵌套 lookup 或从任意复杂结构中搜索 ID。 + +Remote Context 使用独立的 merge-extensible map 和 provider。Agent 包注册 `agent` Context provider,负责用 wire identity 找到 Agent Context,并从该 Context 解析 descriptor 指定的 service key;Gateway 不知道 Agent Context 的内部结构。 + +Client 侧也注册 `agent` Context binder。binder 只负责从一次调用所在的 Context 取得 `SessionId`;它不枚举 Scope,也不逐个复制方法。scoped namespace 由 Cordis Service tracker 自动 rebind 到当前 Agent Context。 + +## InvocationDescriptor + +TypeRT、SRC 弱解析器、Host Gateway 和 Client API 之间只交换一种规范描述: + +```text +InvocationDescriptor { + id: '@deepseek-ai/dsh-goal#goals/create' + service: 'goals' + namespace: 'goals' + method: 'create' + implementation: 'remoteExportCreate' + invocation: direct | { context: 'agent', wire: 'agentId' } + scope?: { context: 'agent', wire: 'agentId' } + parameters: [ + { name, wire, source: json | lookup, lookup?, codec } + ] + result: codec + sourceLocation +} +``` + +`method` 是 endpoint 和 Client API 使用的外部短名,`implementation` 是 Host receiver 上的真实成员名;两者相同时可省略 `implementation`。`direct` descriptor 保留原始 Service 实例作为 receiver。Context descriptor 先通过对应 Context provider 找到 scoped Context,再以 descriptor 的 service key 解析 receiver。 + +严格生成器只在 direct 方法恰好包含一个 lookup 参数、同名 `TypeRTContextMap` 声明存在且两者使用同一 wire 类型 symbol 时写入 `scope`。`scope.wire` 必须指向该 lookup 参数;它声明消费端可以从调用所在 Context 补入这个参数,不改变 Host receiver 或 endpoint。多个 lookup、缺少 Context 声明或 wire 类型不一致时不生成 scoped 投影,其中类型不一致属于构建错误。 + +参数顺序来自方法签名,HTTP 字段来自参数名或 lookup 声明。Gateway 不根据请求内容推断可选字段、Context 类型、lookup 类型或缺失参数,也不会合成业务默认值。 + +LIB codec 带有 Zod schema 和“package + 公共 subpath + export name”的规范 `typeSymbol`;SRC codec 只标记 `src-json`。Host 和消费端运行在不同 JavaScript realm 时会各自持有 Zod 实例,但这些实例由同一 TypeRT 模型和 symbol key 生成。 + +descriptor 只存在于两端本地 registry。wire 上只有 `/api2` channel、endpoint 和 `{ args }` payload;Host 用自己的 descriptor 解码和调用,Client 用自己的对应 descriptor 编码参数和验证结果。 + +## TypeRT 运行时 registry + +```text +ctx.typert.local 当前进程自己的 Host 或 Client reflection +ctx.typert.remotes 消费端显式 mount 的对端 Remote contribution +ctx.typert.lookups wire ID 到 Host 活对象的 provider +ctx.typert.contexts Host Context resolver 与 Client Context binder +``` + +每次注册都返回由调用方 Cordis fiber 持有的 disposer。Gateway 和 API Service 先读取当前快照再订阅变化,因此业务 Service、generated contribution、provider 和消费者可以按任意顺序加载;任一依赖 dispose 后,相关 endpoint 或方法立即失效。 + +Registry 的 Host 根入口拥有完整 `TypeRTService` interface merge;Host 与 Client 共用的 registry 实现位于无环境声明的独立模块。Registry `/client` 入口只引用该共享实现,不经过 Host 根入口,因此不会把 Host Cordis 声明带入 Client Program。 + +## 唯一类型、符号与 Zod + +Remote Client DTS 不复制业务 DTO,也不重新声明一个结构相同的影子类型。它只从不携带 Host Cordis merge 的公共纯类型 subpath 引用原始符号: + +```text +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { CreateGoalRequest, CreateGoalResult } from '@deepseek-ai/dsh-goal/types' +``` + +因此 `SessionId`、Agent wire ID、request 和 result 在 Host 与 Browser Client 中都指向同一 TypeScript declaration,未来 TUI 复用时也不需要第二份类型。DTO 的跳转定义、重命名和引用查找回到业务类型的唯一源码位置,而不是停在生成文件中的副本。 + +Remote API 方法本身使用 declaration map 导航。TypeRT 把 `InvocationModel.location` 固定在 Host 的 `remoteExport*` 方法名 token,并在 namespace interface 的对应属性上写入 source-map segment;TypeScript editor 从 `ctx.api.models.list` 取得生成 declaration 后,再沿 `typert.remote-client.d.ts.map` 跳到 Host Service 的 `remoteExportList` 远程出口。该出口继续显式调用不改名的存量 `list()`,map 不把 decorator、class 或整个签名误当成方法定义位置。 + +TypeRT 为同一 symbol key 生成 wire Zod codec。Host Gateway 用它校验输入和编码结果,Client API 可以用它编码参数并校验响应;复杂类型无法生成严格 codec 时,LIB 构建失败,不降级为 `unknown` 或无校验 JSON。 + +Remote 方法引用的命名业务类型必须从纯类型公共 subpath 导出。如果唯一可达入口会带入 Host Service、Cordis `Context` merge 或 Host-only 实现,构建失败并要求业务包提供安全的类型出口。原始值、字面量和 TypeRT 明确支持的简单组合不需要额外命名。 + +lookup 参数不会把 `Agent` class 暴露给消费端。Remote 投影引用 lookup 声明中的唯一 ID 类型,例如 `SessionId`;Host 内部仍以唯一的 `Agent` class symbol 完成对象解析。 + +## 三种产物与两个 TypeScript Program + +Host 与 Client 仍然只有两个独立 TypeScript Program,但 TypeRT 生成三种性质不同的产物: + +```text +Host Program +├─ typert.host.js / typert.host.d.ts +│ Host 自身的 Service、Event、Object、schema 和 inbound Gateway 信息 +└─ typert.remote-client.js / typert.remote-client.d.ts / typert.remote-client.d.ts.map + Host Remote 对任意消费环境的 wire 投影 + +Client Program +└─ typert.client.js / typert.client.d.ts + Client 自身的 Service、Event、Object 和 schema 信息 +``` + +`remote-client` 是 Host Program 的第二个 emitter,不是第三个 Program,也不是 Client 本地 face。它不包含 Host Cordis merge、Service class、Context class 或实现代码,不进入 Host 本地 reflection registry。 + +Host lib 构建负责完成严格 Host 分析并产出 Host 本地 artifact 与 Remote 消费端 artifact;Client lib 随后消费 Remote DTS。完整顺序为: + +```text +Host lib build +→ 生成 typert.host.{js,d.ts} +→ 生成各业务包 lib/typert.remote-client.{js,d.ts,d.ts.map} +→ 完成 Client lib 和 typert.client 产物 +→ Vite 构建 Web +``` + +现有顶层 `build` 仍表现为先 `build:lib`、再 `build:web`,但 `build:lib` 内部必须先完成 Host 与 Remote artifact,再启动 Client TypeScript 编译。一次干净构建不能依赖上次残留的 `.d.ts`。 + +## `/remote` 包入口 + +每个提供 Remote 方法的业务包导出生成的 `/remote` 子路径: + +```text +"./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" +} +``` + +消费代码通过业务包本身选择能力: + +```text +import goalsRemote from '@deepseek-ai/dsh-goal/remote' +``` + +该 import 让 `.d.ts` 的 map augmentation 进入当前 TypeScript project,同时把同一契约的 JS descriptor 作为值交给运行时。未 import 的业务包不会扩展当前 project 的 Remote API 类型。 + +业务 package 的发布文件必须同时包含 `lib/typert.remote-client.d.ts.map` 和 map 指向的 `src` 文件。生成 DTS 以 `//# sourceMappingURL=typert.remote-client.d.ts.map` 引用相邻 map;map 中的 source 从 `lib` 相对指向业务源码,例如 `../src/index.ts`。`/remote` export 不单独列出 map,package `files` 负责把它与源码一起发布。 + +仅需要静态类型时可以使用 `import type {} from '@deepseek-ai/dsh-goal/remote'`;这种 import 在运行时会被擦除,不会加载 JS,也不能触发任何运行时注册。需要真实调用的环境必须把普通 value import 得到的 contribution 交给 API Service。 + +workspace 对 `/remote` 的解析必须明确指向 `lib` 生成物,不能被通用 package-to-`src` paths 规则带回 Host 源码。普通业务 import 仍可按各环境既有规则解析到 SRC 或 LIB。 + +## 消费端严格 API 类型 + +Remote DTS 同时扩展平面 endpoint map、direct namespace interface、namespace map 和 scoped map,而不扩展全局 Cordis `Context`: + +```text +interface TypeRTRemoteNamespace$676f616c73 { + create: ( + agentId: SessionId, + request: CreateGoalRequest, + ) => Promise +} + +interface TypeRTRemoteMap { + 'goals/create': ( + agentId: SessionId, + request: CreateGoalRequest, + ) => Promise +} + +interface TypeRTRemoteNamespaceMap { + goals: TypeRTRemoteNamespace$676f616c73 +} + +interface TypeRTRemoteContextMap { + 'agent:goals/create': ( + request: CreateGoalRequest, + ) => Promise +} +``` + +`TypeRTRemoteMap` 保留规范 endpoint 签名,供协议类型和反射使用。根 API 类型直接读取 `TypeRTRemoteNamespaceMap`,不通过 key-remapped mapped type 间接推导方法;TypeScript Language Service 无法把这种间接属性稳定导航到 declaration map。namespace interface 名由 namespace 的 UTF-8 bytes 编成 hex,`goals` 因而稳定得到 `TypeRTRemoteNamespace$676f616c73`。不同 package 对同一 namespace 生成同名 interface,依靠 module augmentation 合并各自方法,且 `TypeRTRemoteNamespaceMap.goals` 始终引用同一类型。 + +TypeRT 把 `TypeRTRemoteContextMap` 按 Context key 投影到专用 Scope 类型。最终编程界面保持: + +```text +api.goals.create(agentId, request) +agent.goals.create(request) +``` + +Agent Scope 自动提供自己的 `SessionId`。因此带 `agent` lookup 的 `@Remote` 方法可以同时生成 root 和 scoped 两种消费端签名;`@RemoteContext('agent')` 方法也省略独立的 Context identity,但只生成 scoped 签名。本期只有 Client Agent Context 获得 `goals`,Root Context 不获得该属性;未来 TUI 复用时必须维持相同的 Scope 限制。 + +`RemoteApi` 保持平台无关,Browser Client 把它作为自己的 `ClientApi`。未来 TUI 若复用该类型,也必须通过专用 API 对象和 Agent Scope 使用它,不能把 Host `Context` 当成更宽的 Service 集合;未标记的 public Service 方法不会进入 Remote maps。 + +## Client TypeRT 与 API Gateway Client face + +一个消费环境的 TypeRT 同时维护本地信息和从其他环境导入的 Remote 信息,但两者存放在不同 registry: + +```text +TypeRT.local 当前环境自己的反射模型 +TypeRT.remotes 已导入的 Remote contribution +``` + +`@deepseek-ai/dsh-client-remotes/client` 集中加载需要的 Remote contribution: + +```text +import goalsRemote from '@deepseek-ai/dsh-goal/remote' +import sessionsRemote from '@deepseek-ai/dsh-session/remote' + +ctx.api.mount(goalsRemote) +ctx.api.mount(sessionsRemote) +``` + +Client 业务包只引用 `@deepseek-ai/dsh-client-remotes/client`,不直接依赖 Host API Gateway 或各业务 `/remote` 运行时入口。Client Remotes 自己依赖 Gateway Client face,并通过声明 re-export 把所选 Remote map 传给业务编译;新增或移除整套 Client 能力只修改这一处 assembly。 + +`ctx.api.mount()` 把 contribution 注册到 `TypeRT.remotes`,并由调用该方法的 Cordis fiber 持有 disposer。endpoint 重复、同一 namespace/method 模式冲突或 descriptor 与现有类型身份冲突时直接失败。 + +API Service 把 `@Remote` descriptor 实体化为根 `api` 上的真实函数。函数按 descriptor 的位置参数顺序构造具名 `args`,执行 Client strict codec,然后调用 `ctx.connection.rpc.call('/api2', endpoint, { args })`。 + +带 `scope` 的 direct descriptor 和 `@RemoteContext` descriptor 都不为每个 Agent Scope 复制函数。API Service 为每个 scoped namespace 建立一个 root singleton Cordis Service,并在该 Service 上实体化方法;Cordis tracker 在 `agent.goals.create()` 调用时把 Service 的 `this.ctx` rebind 到当前 Agent Context。方法再通过对应 Context binder 从 `this.ctx` 取得 identity。direct scoped 投影用 identity 替代 `scope.wire` 指定的 lookup 位置,Context descriptor 则把 identity 写入 receiver 的独立 wire 字段;两者都发起同一种 `/api2` 调用。 + +```text +root ctx.api.goals.create(agentId, request) + → direct descriptor + → ctx.connection.rpc.call('/api2', 'goals/create', { args }) + +agent.goals.create(request) + → tracker 将 namespace Service rebind 到 agent Context + → agent binder 从 caller Context 取得 agentId + → 用 agentId 补入同一 direct descriptor 的 lookup 参数 + → ctx.connection.rpc.call('/api2', 'goals/create', { args }) +``` + +Root `Context` 不 merge scoped `goals` 类型;只有 `AgentContext` 通过 `RemoteContextApi<'agent'>` 获得该属性。若调用方绕过类型从 Root 动态调用 scoped 方法,binder 明确报错。若 Client 已有同名 Cordis service,或两个 contribution 冲突占用同一 namespace/method,mount 直接失败,不覆盖现有服务。 + +生成的 Remote JS 只包含 descriptor、symbol key 和 codec,不打包 Host Service 实现。API Service 可以据此创建真实函数,因此本方案不依赖 JavaScript Proxy;Proxy 可以作为实现选择,但不会成为类型或反射来源。 + +## 跨环境同构约束 + +Remote API 是消费端能力,不等同于 Browser API。本期只实现 Browser Client 的 contribution 挂载、Connection RPC 调用和 Agent Scope 关联。 + +Remote DTS、Remote JS、`RemoteApi`、`InvocationDescriptor`、Remote RPC 数据协议和 Context binder 不得依赖 DOM、Browser module loader 或 HTTP。Browser Client 通过 Connection 把 descriptor 实体化的方法编码为 `/api2` RPC 调用。 + +未来 TUI 可以在不改变业务 decorator、Remote maps 和 API 调用形状的前提下接入同一调用抽象。届时 TUI 可见的 API 仍只能由 `@Remote` 和 `@RemoteContext` 生成,不能因为它与 Host 同进程就绕过 Remote 限制直接暴露 Service 方法。 + +TUI 的 runtime 挂载、carrier、Agent Scope 关联和 SRC 启动接线均不属于本期实现。 + +Web 本身依赖 `lib/client.js` 等构建产物,因此启动 Web 前要求完整 `build:lib`。Host Remote 契约变化后必须重新执行 lib build,再启动或重启 Web;本方案不在第一阶段实现 Remote contract 的增量 watch。 + +## SRC 与 LIB 运行模式 + +SRC 面向本地源码启动。`@Remote` 和 `@RemoteContext()` 的 WeakMap 记录给出方法名和调用模式,运行时从 JavaScript 函数签名读取顺序参数名,并结合已注册 lookup/Context provider 生成弱 descriptor。 + +例如 `@Remote('create') remoteExportCreate(agent, request)` 解析为外部方法 `create`、实现成员 `remoteExportCreate` 和两个顶层参数;lookup 注册把 `agent` 改写为 wire 字段 `agentId`,`request` 按同名 JSON 参数传递。SRC 不启动 `ts.Program`,不使用 preload、loader hook、源码生成或模块改写,也不检查普通 JSON 对象的内部结构。 + +SRC 无法明确解析的签名在 Service 挂载时失败。对象解构、默认参数造成的歧义、rest 参数、嵌套 lookup 和复杂类型不做猜测。 + +LIB 面向 CI、发布和 Web 前置构建。TypeRT 扫描完整 Host project,检查 Remote decorator、显式 binding、service key、endpoint 冲突、lookup/Context 声明、公共符号可达性、JSON codec 和结果 codec,并生成严格 descriptor。 + +LIB 运行时只加载 `lib` 中的 definition,不启动 TypeScript compiler。Host Gateway 后续的 Service 关联、lookup、Context 解析、调用和响应编码不区分 descriptor 来自 SRC 弱解析还是 LIB 严格生成。 + +CI 和发布运行 LIB。全仓 coverage 全部切换到 LIB 是独立后续工作,不阻塞本次直接方法调用实现。 + +## Host Gateway 注册 + +Host Gateway 同时观察 TypeRT Remote definition 和 Cordis Service 生命周期。当某个带 `typertGateway` facet 的 Service 与同 service key 的 definition 都可用时,Gateway 注册其 endpoint;两者到达顺序不影响结果。 + +Gateway 启动时先读取 TypeRT definition 和 Cordis reflection store 的当前快照,再订阅 registry change 与 `internal/service`。它按 service key reconcile definition、活 Service 和 binding;Service 被替换或 dispose 时撤销对应 endpoint。definition、lookup provider 或 Context provider 撤销时,依赖它们的 endpoint 立即不可调用,不保留失效对象或降级为原始 ID 调用。 + +普通 `@Remote` 调用保留原始 Service 实例作为 receiver。lookup 成功后,Gateway 按 descriptor 的参数顺序调用 `implementation ?? method` 指定的成员。 + +`@RemoteContext('agent')` 调用先由 Agent Context provider 解析 wire identity,再从该 Context 读取 descriptor 的 service key 并调用 scoped receiver。业务方法不会收到隐藏 Context 参数或 Agent ID。 + +```text +ctx.typertGateway.invoke({ namespace, method, args }) +→ 查找本地 InvocationDescriptor 与 live receiver +→ 按参数 descriptor 读取具名 wire 字段 +→ codec 解码普通值或 lookup ID +→ lookup provider 把 ID 解析为活对象 +→ direct 使用原 Service;context 先解析 scoped Context 和 Service +→ Reflect.apply(receiver[implementation ?? method], receiver, orderedArgs) +→ result codec 编码业务结果 +``` + +`ctx.typertGateway.invoke()` 是 carrier-independent 的 Host 入口。它不创建 rpcId、RPC envelope 或 HTTP response;它只返回编码结果,或产生由 Connection RPC adapter 映射的 Gateway 错误。 + +## `/api2` 调用链 + +`/api2` 是唯一 Connection/RPC 机制上的独立协议 channel,不是 Gateway 自建的 transport。Gateway 只向 Connection 注册一个本地 handler;本期在现有 HTTP Connection 中增加这项通用 channel 能力: + +```text +ctx.connection.rpc.handle('/api2', (endpoint, payload) => { + const { namespace, method } = parseEndpoint(endpoint) + const { args } = parsePayload(payload) + return ctx.typertGateway.invoke({ namespace, method, args }) +}) +``` + +Connection Host half 从唯一 HTTP Server 取得 handle,复用同一 RPC bridge、request/response envelope、rpcId、序列化、trust、transport error 和 `RpcError`。当前物理映射是: + +```text +POST /api2// +``` + +Remote payload 使用具名 JSON 对象,不使用位置数组,也不发送 `InvocationDescriptor`。普通 Goal 调用的 payload slot 是: + +```json +{ + "args": { + "agentId": "session-1", + "request": { + "objective": "finish the migration" + } + } +} +``` + +完整链路为: + +```text +ctx.api.goals.create(sessionId, request) +→ Client InvocationDescriptor 编码 { args: { agentId, request } } +→ ctx.connection.rpc.call('/api2', 'goals/create', { args }) +→ Connection 创建 rpcId 和既有 client-request envelope +→ 当前 carrier 发送 POST /api2/goals/create +→ Connection Host half 执行 trust、反序列化和 RPC 分发 +→ /api2 handler 调用 ctx.typertGateway.invoke(...) +→ Host InvocationDescriptor 解码、lookup、receiver 解析和 Reflect.apply +→ result codec 编码 +→ Connection 写入既有 RPC result 并回送相同 rpcId +→ Client result codec 验证并返回 CreateGoalResult +``` + +Remote 不定义第二层 `{ ok, value/error }` response。成功值和 Gateway 错误直接使用既有 RPC response 的 `result`;Gateway adapter 负责把 endpoint、schema、lookup、Context、Service 和业务调用失败映射为 `RpcError`,Connection 负责传输该错误。 + +Gateway 不处理逐方法权限、调用者身份、取消、幂等或长连接状态。本工作只扩展 Connection 的通用 channel 注册和调用能力,不改变现有 `/api`、trusted connection、trusted-host 或 privileged method 语义;Connection/WebSocket 迁移后续独立完成。 + +## Connection 与协议边界 + +API Service 负责 Remote contribution、方法实体化、Scope 绑定以及位置参数与 descriptor 的对应。Gateway 负责 Host descriptor、lookup、Context 和业务调用。Connection 只负责把 `/api2`、endpoint 和 `{ args }` 作为一个 RPC 调用发送到目标并返回既有 RPC result;它不理解 Goal、Agent、lookup、descriptor 或 Client API 类型。 + +`/api` 与 `/api2` 共享唯一 Connection、Server、RPC envelope 和连接生命周期,但保持协议隔离。Connection 从 HTTP 迁移到 WebSocket 时,`/api2` 从物理路径自然变成逻辑 channel;Remote payload、业务 decorator、生成的 DTS、Remote API 类型和 Agent Scope 编程界面都不变化。 + +## 包边界 + +- `@deepseek-ai/dsh-type-meta`:轻量 decorator、binding、lookup、Remote Context 和 descriptor 协议。 +- TypeRT generator:分析 Host/Client Program,生成本地 face 和 Remote 消费端投影,并生成规范 symbol/Zod 信息。 +- TypeRT runtime:分别保存当前环境的 local reflection 与导入的 Remote contribution。 +- `@deepseek-ai/dsh-host-api-gateway`:默认入口关联 Host definition 与 Service,执行 lookup、Context receiver 解析、调用和结果编码,并向 Connection 注册 `/api2` handler;`/client` 入口挂载 Remote contribution,创建严格 API 方法,并把调用交给 `ctx.connection.rpc`。两个入口共享 Remote 协议,但不互相导入各自的 Cordis interface merge。 +- `@deepseek-ai/dsh-client-remotes`:Client 业务唯一依赖的 Remote facade;直接依赖 Gateway Client face,选择 `/remote` contributions,并向业务包传递合并后的 API 类型。 +- Connection:拥有唯一 HTTP Server/未来 WebSocket carrier、RPC envelope、rpcId、序列化、trust 和错误传输,同时承载隔离的 `/api` 与 `/api2` channel。 +- Agent/Session 等业务对象包:拥有 lookup、Context provider、唯一 ID 类型和纯类型公共出口。 +- 业务 Service 包:声明 binding、Remote 方法及其 request/result 类型,并导出生成的 `/remote` 子路径。 + +## 首期实现范围 + +第一条纵向链路实现 `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api2 → Host Gateway → GoalService.remoteExportCreate()`,并证明同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.api.goals.create(agentId, request)` 与 `agentCtx.goals.create(request)`。`@RemoteContext('agent')` 的 scoped receiver 语义继续保留为独立模式。 + +本期实现 Connection 的通用第二 channel API 及当前 HTTP carrier 映射,但不实现 WebSocket 迁移、TUI runtime、TUI carrier 或 TUI Agent Scope 接线。本 RFC 也不设计 Permission/Approval 状态机、Session 事件流、调用授权、取消、重试、幂等和跨版本协议兼容。 + +## Alternatives considered + +**继续使用中央 API Proxy 包。** 该方案要求业务方法、Host 路由和 Client 接口在多个位置重复声明,也会继续把直接调用、带状态交互和事件流绑在同一生命周期中,因此不采用。 + +**让 decorator 在运行时完成严格反射。** JavaScript decorator 无法恢复擦除后的 TypeScript 类型、公共符号身份和完整 Zod codec;向 constructor 注入 compiler 私有 symbol 又会隐藏业务类的真实依赖,因此严格信息由 TypeRT compiler 生成。 + +**SRC 启动时使用 preload、loader hook 或完整 `ts.Program`。** 这能复用 LIB 分析,但增加所有源码启动入口的要求。SRC 只需要可用的弱 descriptor,因此采用 decorator 标记、函数参数名和显式 provider;严格检查留给 LIB contract pass。 + +**手写 Client interface。** 手写接口不能保证只包含 Remote 标记的方法,也会与 Host 签名、lookup ID 和 Zod schema 漂移,因此 Client 类型从 Host Program 自动投影。 + +**使用 TypeScript language-service/compiler plugin 让 Client 直接理解 decorator。** 这会让编辑器、Vite、tsc、tsx 和发布消费者都依赖额外插件,接入面过大,因此生成普通 `.d.ts` 和标准 declaration map。 + +**把完整 Host DTS 导入 Client 或 TUI。** 该方案会带入 Host Service 和 Cordis interface merge,并向消费端暴露未标记方法。Remote DTS 只引用纯类型公共符号并扩展专用 Remote maps。 + +**只生成 Remote DTS,不生成 JS。** 类型可以成立,但运行时无法枚举 endpoint、codec 和 Context 模式,只能依赖 Proxy 或另一份手写注册表,因此同一次 Host 投影同时生成 Remote JS contribution。 + +**让 `/remote` 的顶层 import 偷偷注册全局状态。** ESM 求值时未必已有目标 Cordis Context,多个 Context、HMR 和 dispose 也无法明确归属,因此普通 value import 只返回 contribution,由环境 assembly 的 API Service 显式挂载。 + +**为 Remote 新建独立 transport、HTTP route 和响应信封。** 这会复制现有 Connection 的 Server ownership、rpcId、序列化、trust、错误和未来 WebSocket 生命周期,并让两个 RPC 栈分别迁移,因此 `/api2` 作为独立协议 channel 复用唯一 Connection/RPC 机制。 + +## Acceptance criteria + +- Goal Service 保留既有业务方法,在类末尾通过显式 `typertGateway` 和 `@Remote('create') remoteExportCreate(...)` 新增远程出口,不维护第二份路由、codec 或 Client 方法清单。 +- 一次干净 `build:lib` 先生成 Host Remote contract,再完成 Host 和 Client 消费端编译,并在业务包 `lib` 下产生可通过 `/remote` 导入的 JS、DTS 和 DTS map。 +- 导入 `@deepseek-ai/dsh-goal/remote` 后,消费 project 获得严格的 `api.goals.create(...)` 类型;不导入时该 namespace 不进入类型;从 `create` 跳转定义会通过 declaration map 到达 Host Service 的 `remoteExportCreate` 实现。 +- Client assembly 挂载同一个 import 得到的 JS contribution 后,TypeRT 能反射 endpoint、参数、结果、lookup、Context 和 Zod 信息,API Service 无需手写 stub 即可创建调用方法。 +- Remote DTS、Remote JS、`RemoteApi` 和 descriptor 协议不依赖 Browser 专属能力,且类型模型无法暴露未标记的 Goal Service 方法,为未来 TUI 同构接入保留边界。 +- `agent.goals.*` 通过 Cordis tracker 和 Context binder 取得调用 Scope,Root Context 不获得 Agent-only 类型,且不为每个 Scope 复制函数。 +- `/api2/goals/create` 能把 `agentId` 解析为唯一 Agent 对象,调用原始 Goal Service receiver,并通过既有 RPC result/error 返回结果。 +- `/api2` 与 `/api` 共享唯一 Connection/RPC carrier,但保持协议隔离;Remote 不直接注册 HTTP Server handle,也不定义第二套 response envelope。 +- Connection 提供通用 channel 注册和调用能力,并把 `/api2` 映射到当前 HTTP carrier;现有 `/api` 行为与 trust 语义保持不变。 +- 现有 `/api`、Connection/trusted connection、Permission/Approval 和 Session 事件流行为不因本实现改变。 + +## Risks + +Remote API 类型依赖生成的 `lib` 声明,构建编排必须在 Host 和 Client 消费端编译前完成 contract pass;顺序错误会让干净构建依赖陈旧产物。 + +源码导航依赖 Remote package 同时发布 declaration map 和 map 指向的 `src`。package `files` 漏掉任一侧时类型仍可编译,但消费端跳转会停在生成 DTS,因此 workspace manifest 校验必须把两者作为同一发布契约。 + +SRC 弱 descriptor 不验证普通 JSON 内部结构。Host Remote 签名变化后,Web 和严格类型消费者必须重新执行 lib build;第一阶段没有增量 contract watch。 + +公共类型唯一性要求业务 DTO 具有纯类型出口,可能暴露现有包中 Host 类型与实现入口混杂的问题。构建会拒绝这些边界,而不是复制类型掩盖问题。 + +类型 import 与运行时 contribution 是两种不同效果。`import type {}` 只扩展静态 API;真实调用环境遗漏 value contribution 时,API Service 必须以明确的“Remote 未挂载”错误失败。 + +Browser 与 Host 各自持有 Zod 实例,不能依赖对象 identity 跨 realm 比较;一致性只由规范 symbol key、同一生成模型和 wire 行为保证。 + +消费端可以导入 Host 当前未挂载的 Remote contract。类型表示“该协议能力已被消费端选择”,不保证目标进程当前存在对应 Service;运行时 endpoint 不可用必须明确失败。 + +Connection 的通用 channel API 必须同时适合当前 HTTP carrier 和后续 WebSocket carrier。若接口把 `fetch`、HTTP request 或 route handle 暴露给 Gateway/API Service,WebSocket 迁移会再次穿透 Remote 层,因此这些物理对象必须留在 Connection 内部。 From 64a963da0b42f9cd389d133656f73b1936760c41 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:17:47 +0800 Subject: [PATCH 058/104] feat: add TypeRT remote gateway infrastructure --- apps/cli/composition.md | 9 + docs/capability-seams.md | 7 +- docs/config-catalog.md | 8 +- docs/cordis-catalog/events.md | 8 +- docs/cordis-catalog/services.md | 31 +- docs/module-graph.md | 14 +- package.json | 5 +- packages/bundle/base/cordis.patch.yml | 9 + packages/bundle/base/package.json | 3 + .../client/connection/src/client/index.ts | 7 + packages/client/connection/src/client/rpc.ts | 75 ++ packages/client/connection/src/index.ts | 101 +- packages/client/connection/src/rpc-host.ts | 150 +++ packages/client/connection/src/rpc.ts | 59 ++ .../connection/tests/client-apply.spec.ts | 37 + .../client/connection/tests/node-half.spec.ts | 92 +- .../client/runtime/tests/client-apply.spec.ts | 3 + .../client/runtime/tests/wire-events.spec.ts | 3 + packages/client/tsdown.client.ts | 7 +- .../cordis/tool-cordis/src/api-catalog.ts | 46 +- packages/core/agent/package.json | 8 + packages/core/agent/src/index.ts | 25 + packages/core/agent/tests/agent.spec.ts | 26 + packages/core/agent/tsconfig.json | 3 + packages/core/session/package.json | 3 + packages/core/session/src/index.ts | 16 + packages/core/session/tests/typert.spec.ts | 26 + packages/core/session/tsconfig.json | 3 + packages/host/api-gateway/README.i18n.yaml | 6 + packages/host/api-gateway/README.md | 36 + packages/host/api-gateway/README.zh.md | 36 + packages/host/api-gateway/package.json | 68 ++ packages/host/api-gateway/src/client/index.ts | 370 ++++++++ packages/host/api-gateway/src/index.ts | 604 ++++++++++++ packages/host/api-gateway/src/invariant.ts | 30 + packages/host/api-gateway/src/types.ts | 52 ++ .../host/api-gateway/tests/client.spec.ts | 222 +++++ .../host/api-gateway/tests/gateway.spec.ts | 795 ++++++++++++++++ packages/host/api-gateway/tsconfig.json | 27 + packages/host/api-gateway/tsdown.config.ts | 3 + packages/host/apiproxy/src/api/index.ts | 5 + packages/typert/generator/package.json | 1 + packages/typert/generator/src/analyzer.ts | 869 +++++++++++++++++- .../typert/generator/src/cordis-catalog.ts | 2 +- packages/typert/generator/src/emitter.ts | 538 ++++++++++- packages/typert/generator/src/model.ts | 55 ++ packages/typert/generator/src/renderer.ts | 101 +- .../typert/generator/src/tsdown-plugin.ts | 79 +- packages/typert/generator/src/workspace.ts | 42 +- .../__snapshots__/type-model.spec.ts.snap | 5 + .../tests/fixtures/remote-model/package.json | 5 + .../remote-model/packages/domain/package.json | 9 + .../remote-model/packages/domain/src/index.ts | 19 + .../remote-model/packages/domain/src/types.ts | 2 + .../packages/domain/tsconfig.json | 11 + .../remote-model/packages/remote/package.json | 24 + .../remote-model/packages/remote/src/index.ts | 30 + .../remote-model/packages/remote/src/types.ts | 20 + .../packages/remote/tsconfig.json | 14 + .../fixtures/remote-model/tsconfig.base.json | 20 + .../fixtures/remote-model/tsconfig.host.json | 8 + .../fixtures/remote-model/type-meta.d.ts | 45 + .../generator/tests/remote-model.spec.ts | 486 ++++++++++ .../generator/tests/schema-emitter.spec.ts | 238 ++++- .../generator/tests/tools-catalog.spec.ts | 2 +- .../generator/tests/tsdown-plugin.spec.ts | 81 ++ .../typert/generator/tests/type-model.spec.ts | 98 ++ packages/typert/loader/src/index.ts | 91 +- packages/typert/loader/tests/loader.spec.ts | 210 +++++ packages/typert/registry/package.json | 15 + packages/typert/registry/src/client/index.ts | 15 + packages/typert/registry/src/index.ts | 220 +---- packages/typert/registry/src/service.ts | 584 ++++++++++++ packages/typert/registry/src/types.ts | 8 + packages/typert/registry/tests/typert.spec.ts | 184 +++- packages/typert/registry/tsconfig.json | 3 + packages/typert/registry/tsdown.config.ts | 26 +- packages/typert/type-meta/README.i18n.yaml | 6 + packages/typert/type-meta/README.md | 33 + packages/typert/type-meta/README.zh.md | 33 + packages/typert/type-meta/package.json | 42 + packages/typert/type-meta/src/index.ts | 223 +++++ packages/typert/type-meta/src/invariant.ts | 30 + packages/typert/type-meta/src/types.ts | 358 ++++++++ .../type-meta/tests/fixtures/source-launch.ts | 29 + .../typert/type-meta/tests/type-meta.spec.ts | 132 +++ packages/typert/type-meta/tsconfig.json | 21 + pnpm-lock.yaml | 61 ++ scripts/client-bundle-purity.spec.ts | 7 + scripts/gen-cordis-catalog.ts | 2 + scripts/gen-doc-graphs.ts | 11 +- .../verify-package-readme-model-experience.ts | 2 + tsconfig.base.json | 10 +- tsconfig.client.json | 2 + tsconfig.host.json | 2 + tsdown.config.ts | 4 + tsdown.typert-host.config.ts | 20 + vitest.config.ts | 30 +- 98 files changed, 7812 insertions(+), 444 deletions(-) create mode 100644 packages/client/connection/src/client/rpc.ts create mode 100644 packages/client/connection/src/rpc-host.ts create mode 100644 packages/client/connection/src/rpc.ts create mode 100644 packages/core/session/tests/typert.spec.ts create mode 100644 packages/host/api-gateway/README.i18n.yaml create mode 100644 packages/host/api-gateway/README.md create mode 100644 packages/host/api-gateway/README.zh.md create mode 100644 packages/host/api-gateway/package.json create mode 100644 packages/host/api-gateway/src/client/index.ts create mode 100644 packages/host/api-gateway/src/index.ts create mode 100644 packages/host/api-gateway/src/invariant.ts create mode 100644 packages/host/api-gateway/src/types.ts create mode 100644 packages/host/api-gateway/tests/client.spec.ts create mode 100644 packages/host/api-gateway/tests/gateway.spec.ts create mode 100644 packages/host/api-gateway/tsconfig.json create mode 100644 packages/host/api-gateway/tsdown.config.ts create mode 100644 packages/typert/generator/tests/fixtures/remote-model/package.json create mode 100644 packages/typert/generator/tests/fixtures/remote-model/packages/domain/package.json create mode 100644 packages/typert/generator/tests/fixtures/remote-model/packages/domain/src/index.ts create mode 100644 packages/typert/generator/tests/fixtures/remote-model/packages/domain/src/types.ts create mode 100644 packages/typert/generator/tests/fixtures/remote-model/packages/domain/tsconfig.json create mode 100644 packages/typert/generator/tests/fixtures/remote-model/packages/remote/package.json create mode 100644 packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts create mode 100644 packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/types.ts create mode 100644 packages/typert/generator/tests/fixtures/remote-model/packages/remote/tsconfig.json create mode 100644 packages/typert/generator/tests/fixtures/remote-model/tsconfig.base.json create mode 100644 packages/typert/generator/tests/fixtures/remote-model/tsconfig.host.json create mode 100644 packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts create mode 100644 packages/typert/generator/tests/remote-model.spec.ts create mode 100644 packages/typert/registry/src/client/index.ts create mode 100644 packages/typert/registry/src/service.ts create mode 100644 packages/typert/type-meta/README.i18n.yaml create mode 100644 packages/typert/type-meta/README.md create mode 100644 packages/typert/type-meta/README.zh.md create mode 100644 packages/typert/type-meta/package.json create mode 100644 packages/typert/type-meta/src/index.ts create mode 100644 packages/typert/type-meta/src/invariant.ts create mode 100644 packages/typert/type-meta/src/types.ts create mode 100644 packages/typert/type-meta/tests/fixtures/source-launch.ts create mode 100644 packages/typert/type-meta/tests/type-meta.spec.ts create mode 100644 packages/typert/type-meta/tsconfig.json create mode 100644 tsdown.typert-host.config.ts diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 1da393bd06..0246f6163f 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -18,6 +18,12 @@ flowchart LR cfg --> plugin_dsh_base_llm plugin_dsh_base_session["session
    @deepseek-ai/dsh-session"] cfg --> plugin_dsh_base_session + plugin_dsh_base_typert["typert
    @deepseek-ai/dsh-typert-registry"] + cfg --> plugin_dsh_base_typert + plugin_dsh_base_typert_loader["typert-loader
    @deepseek-ai/dsh-typert-loader"] + cfg --> plugin_dsh_base_typert_loader + plugin_dsh_base_typert_gateway["typert-gateway
    @deepseek-ai/dsh-host-api-gateway"] + cfg --> plugin_dsh_base_typert_gateway plugin_dsh_base_session_title["session-title
    @deepseek-ai/dsh-session-title"] cfg --> plugin_dsh_base_session_title plugin_dsh_base_session_title_llm["session-title-llm
    @deepseek-ai/dsh-session-title-first-message-llm"] @@ -159,6 +165,9 @@ flowchart LR | `repository-plugins` | `@deepseek-ai/dsh-repository-plugin` | | `llm` | `@deepseek-ai/dsh-llm` | | `session` | `@deepseek-ai/dsh-session` | +| `typert` | `@deepseek-ai/dsh-typert-registry` | +| `typert-loader` | `@deepseek-ai/dsh-typert-loader` | +| `typert-gateway` | `@deepseek-ai/dsh-host-api-gateway` | | `session-title` | `@deepseek-ai/dsh-session-title` | | `session-title-llm` | `@deepseek-ai/dsh-session-title-first-message-llm` | | `user-interaction` | `@deepseek-ai/dsh-user-interaction` | diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 6a8f7943c2..18839bf3c2 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -32,6 +32,8 @@ flowchart LR pkg_typert_registry["typert-registry"] svc_typert["ctx.typert
    Runtime type registry"] pkg_typert_loader["typert-loader"] + pkg_api_gateway["api-gateway"] + svc_typertGateway["ctx.typertGateway
    TypeRT Host invocation gateway"] svc_sessionPersistence["ctx.sessionPersistence
    Durable session persistence seam"] pkg_session_persistence_jsonl["session-persistence-jsonl"] pkg_session_persistence_sqlite["session-persistence-sqlite"] @@ -171,6 +173,7 @@ flowchart LR pkg_acp --> svc_approval pkg_agent --> svc_agents pkg_agent_loop --> svc_agentLoop + pkg_api_gateway --> svc_typertGateway pkg_approval --> svc_approval pkg_bash --> svc_bash pkg_bash_env --> svc_bashEnv @@ -347,6 +350,7 @@ flowchart LR svc_tools --> pkg_tool_subagent svc_tools --> pkg_tool_todo svc_tools --> pkg_tool_web + svc_typert --> pkg_api_gateway svc_typert --> pkg_typert_loader svc_userInteraction --> pkg_tool_ask_user svc_web --> pkg_tool_web @@ -363,7 +367,8 @@ flowchart LR | `ctx.toolResultPrune` | `core` | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | - | [`compact-basic`](../packages/compact/compact-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), [`cli-demo`](../packages/examples/cli-demo), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | | `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. | -| `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader) | - | Plugins register live zod contributions directly or through dsh-typert-loader; runtime consumers query schemas and reflection metadata at their own edges. | +| `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), `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` | - | - | - | 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-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | | `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-local`](../packages/settings/settings-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `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.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), `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. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f9fa6e8bb2..5728ac4bed 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -291,7 +291,7 @@ Source: [`packages/examples/cli-demo/src/index.ts:26`](../packages/examples/cli- ## `@deepseek-ai/dsh-client-connection` -Requires: `httpServer` · `apiProxy` +Requires: `httpServer` ```ts config-catalog /** Plugin config: the deployment's non-loopback serving authorities. */ @@ -308,7 +308,7 @@ export interface ConnectionConfig { } ``` -Source: [`packages/client/connection/src/index.ts:21`](../packages/client/connection/src/index.ts) +Source: [`packages/client/connection/src/index.ts:31`](../packages/client/connection/src/index.ts) ## `@deepseek-ai/dsh-client-hmr` @@ -2548,6 +2548,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-commands` ([`packages/ui/commands/src/index.ts`](../packages/ui/commands/src/index.ts)) - `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts)) - `@deepseek-ai/dsh-goal-session` — requires `agents` · `goals` · `sessions` ([`packages/goal/goal-session/src/index.ts`](../packages/goal/goal-session/src/index.ts)) +- `@deepseek-ai/dsh-host-api-gateway` — requires `typert` ([`packages/host/api-gateway/src/index.ts`](../packages/host/api-gateway/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker-auto` — requires `httpServer` · `loader` ([`packages/host/directory-picker-auto/src/index.ts`](../packages/host/directory-picker-auto/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker-native` ([`packages/host/directory-picker-native/src/index.ts`](../packages/host/directory-picker-native/src/index.ts)) - `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) @@ -2563,6 +2564,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-timeout-policy` — requires `tools` ([`packages/timeout/timeout-policy/src/index.ts`](../packages/timeout/timeout-policy/src/index.ts)) - `@deepseek-ai/dsh-tool-ask-user` — requires `tools` · `userInteraction` ([`packages/ui/tool-ask-user/src/index.ts`](../packages/ui/tool-ask-user/src/index.ts)) - `@deepseek-ai/dsh-tool-subagent-control` — requires `tools` · `subagents` ([`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts)) +- `@deepseek-ai/dsh-tool-todo` — requires `tools` ([`packages/todo/tool-todo/src/index.ts`](../packages/todo/tool-todo/src/index.ts)) - `@deepseek-ai/dsh-typert-registry` ([`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts)) - `@deepseek-ai/dsh-user-interaction` ([`packages/ui/user-interaction/src/index.ts`](../packages/ui/user-interaction/src/index.ts)) - `@deepseek-ai/dsh-workspace` — requires `storageDomain` · `sessionPersistence` ([`packages/workspace/workspace/src/index.ts`](../packages/workspace/workspace/src/index.ts)) @@ -2620,4 +2622,6 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-subagent-inprocess` ([`packages/subagent/subagent-inprocess/src/index.ts`](../packages/subagent/subagent-inprocess/src/index.ts)) - `@deepseek-ai/dsh-telemetry` ([`packages/sdk/telemetry/src/index.ts`](../packages/sdk/telemetry/src/index.ts)) - `@deepseek-ai/dsh-timeout` ([`packages/util/timeout/src/index.ts`](../packages/util/timeout/src/index.ts)) +- `@deepseek-ai/dsh-type-meta` ([`packages/typert/type-meta/src/index.ts`](../packages/typert/type-meta/src/index.ts)) - `@deepseek-ai/dsh-typert-generator` ([`packages/typert/generator/src/index.ts`](../packages/typert/generator/src/index.ts)) +- `@deepseek-ai/dsh-typert-registry` ([`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts)) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 955adbe234..348d334e9f 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -542,7 +542,7 @@ Creation announcement during session publication. A synchronous throw vetoes and Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:73`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:74`](../../packages/core/session/src/index.ts) ### `session/disposed` — emit @@ -563,7 +563,7 @@ Emitted once when an announced session leaves the store, including publication r Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:83`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:84`](../../packages/core/session/src/index.ts) ### `session/event` — emit @@ -586,7 +586,7 @@ Post-commit, fire-and-forget append feed. The listener snapshot resolves before Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:95`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:96`](../../packages/core/session/src/index.ts) ### `session/flush` — parallel @@ -606,7 +606,7 @@ Awaited parallel durability checkpoint: every listener runs and the caller await Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:104`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:105`](../../packages/core/session/src/index.ts) ## `settings/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index b2f667681d..0a9af0bae5 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -216,7 +216,7 @@ roots(): Agent[] Types: [Agent](../core-data-structures/core.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/agent/src/index.ts:242`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:253`](../../packages/core/agent/src/index.ts) ## `ctx.approval` — `ApprovalService` @@ -1748,7 +1748,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [PrepareSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:800`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:807`](../../packages/core/session/src/index.ts) ## `ctx.sessionTitle` — `SessionTitleService` @@ -2527,16 +2527,17 @@ Source: [`packages/core/tools/src/index.ts:739`](../../packages/core/tools/src/i ## `ctx.typert` — `TypertRegistry` -Registry of generated schemas and package reflection. +Registry of generated schemas, package reflection, invocations, and Remote dependency providers. ```ts cordis-catalog /** * Register one generated contribution atomically for the calling fiber. - * Duplicate package-face identities or schema keys reject the whole batch. - * @param contribution - generated schemas and package metadata. + * Duplicate package-face identities, schemas, invocation ids, or endpoints + * reject the whole batch. + * @param contribution - generated schemas, reflection, and Host invocations. * @returns the exact effect disposer that removes this contribution. */ -register(contribution: TypertContribution): () => void +register(contribution: TypertContribution): TypeRTDisposer /** * Look up one schema by `#`. @@ -2584,7 +2585,23 @@ listPackages(filter: TypertPackageFilter = {}): TypertPackageRecord[] toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema.BaseSchema ``` -Source: [`packages/typert/registry/src/index.ts:67`](../../packages/typert/registry/src/index.ts) +Source: [`packages/typert/registry/src/service.ts:319`](../../packages/typert/registry/src/service.ts) + +## `ctx.typertGateway` — `TypertGatewayService` + +Resolve strict generated definitions or conservative SRC markers against current Cordis Services and TypeRT providers. + +```ts cordis-catalog +/** + * Invoke one live Remote method through strict generated reflection or SRC markers. + * @param request - decoded endpoint and exact named wire arguments. + * @returns the validated business result. + * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; business errors retain their identity. + */ +async invoke(request: InvokeRemoteRequest): Promise +``` + +Source: [`packages/host/api-gateway/src/index.ts:94`](../../packages/host/api-gateway/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` diff --git a/docs/module-graph.md b/docs/module-graph.md index 1a7ae5c8d2..fd9ac036a0 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -211,6 +211,7 @@ flowchart TD end subgraph group_host["packages/host"] pkg_frontend_static["frontend-static"] + pkg_host_api_gateway["host-api-gateway"] pkg_host_apiproxy["host-apiproxy"] pkg_host_directory_picker["host-directory-picker"] pkg_host_directory_picker_auto["host-directory-picker-auto"] @@ -272,6 +273,7 @@ flowchart TD pkg_session_telemetry_otel["session-telemetry-otel"] end subgraph group_typert["packages/typert"] + pkg_type_meta["type-meta"] pkg_typert_generator["typert-generator"] pkg_typert_loader["typert-loader"] pkg_typert_registry["typert-registry"] @@ -311,6 +313,7 @@ flowchart TD pkg_host_webserver --> pkg_invariants pkg_storage --> pkg_invariants pkg_subprocess --> pkg_invariants + pkg_type_meta --> pkg_invariants pkg_typert_generator --> pkg_invariants pkg_typert_registry --> pkg_invariants pkg_llm --> pkg_brand @@ -374,6 +377,7 @@ flowchart TD pkg_session --> pkg_invariants pkg_session --> pkg_llm pkg_session --> pkg_scope + pkg_session --> pkg_type_meta pkg_system_prompt --> pkg_invariants pkg_system_prompt --> pkg_llm pkg_system_prompt --> pkg_scope @@ -420,6 +424,9 @@ flowchart TD pkg_credentials_local --> pkg_credentials pkg_credentials_local --> pkg_invariants pkg_credentials_local --> pkg_paths + pkg_host_api_gateway --> pkg_client_connection + pkg_host_api_gateway --> pkg_invariants + pkg_host_api_gateway --> pkg_typert_registry pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm @@ -434,6 +441,7 @@ flowchart TD pkg_agent --> pkg_scope pkg_agent --> pkg_session pkg_agent --> pkg_system_prompt + pkg_agent --> pkg_type_meta pkg_bash --> pkg_invariants pkg_bash --> pkg_sandbox pkg_bash --> pkg_subprocess @@ -1154,6 +1162,7 @@ flowchart TD | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) | | [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) | | [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`invariants`](../packages/support/invariants) | +| [`type-meta`](../packages/typert/type-meta) | `typert` | [`invariants`](../packages/support/invariants) | | [`typert-generator`](../packages/typert/generator) | `typert` | [`invariants`](../packages/support/invariants) | | [`typert-registry`](../packages/typert/registry) | `typert` | [`invariants`](../packages/support/invariants) | | [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) | @@ -1175,7 +1184,7 @@ flowchart TD | [`typert-loader`](../packages/typert/loader) | `typert` | [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | -| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | +| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`type-meta`](../packages/typert/type-meta) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | @@ -1186,10 +1195,11 @@ flowchart TD | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | +| [`host-api-gateway`](../packages/host/api-gateway) | `host` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) | -| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | +| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`type-meta`](../packages/typert/type-meta) | | [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`compact`](../packages/compact/compact) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | diff --git a/package.json b/package.json index 7bd84db93a..9d0cac6d5e 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,10 @@ ], "scripts": { "build": "npm run build:lib && npm run build:web", - "build:lib": "tsc -b && tsdown", + "build:lib": "npm run build:lib:host && npm run build:lib:client", + "build:lib:host": "npm run build:lib:contracts && tsc -b tsconfig.host.json", + "build:lib:contracts": "tsc -b packages/typert/generator && tsdown --config tsdown.typert-host.config.ts", + "build:lib:client": "tsc -b tsconfig.client.json && tsdown", "build:web": "pnpm --filter @deepseek-ai/dsh-frontend run build", "clean": "tsx scripts/clean.ts", "change-scope": "tsx scripts/change-scope.ts", diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index f0577552cc..0b1cc43a50 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -34,6 +34,15 @@ - id: session name: '@deepseek-ai/dsh-session' + - id: typert + name: '@deepseek-ai/dsh-typert-registry' + + - id: typert-loader + name: '@deepseek-ai/dsh-typert-loader' + + - id: typert-gateway + name: '@deepseek-ai/dsh-host-api-gateway' + - id: session-title name: '@deepseek-ai/dsh-session-title' config: diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index c6519171ca..2ec17d9c66 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -49,6 +49,7 @@ "@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-goal-session": "workspace:^", + "@deepseek-ai/dsh-host-api-gateway": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", @@ -95,6 +96,8 @@ "@deepseek-ai/dsh-tool-web": "workspace:^", "@deepseek-ai/dsh-tool-workflow": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-typert-loader": "workspace:^", + "@deepseek-ai/dsh-typert-registry": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 67b47b06c6..521e54160e 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -8,7 +8,9 @@ import type { IApiClient } from './api.ts' import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts' import { FixtureApiClient } from './fixture.ts' import { WebApiClient } from './web-api-client.ts' +import { createUnavailableConnectionRpc, createWebConnectionRpc } from './rpc.ts' import { isLoopbackHostname } from '../loopback-hostname.ts' +import type { ClientConnectionRpc } from '../rpc.ts' // ---- Contract re-exports (browser-safe apiproxy channels + core types) ---- export type { @@ -36,6 +38,7 @@ export { // Connection loop types are public through ConnectionHandle.start; the // controller remains package-internal. export type { ConnectionConfig, ConnectionSinks, ConnectionState } +export type { ClientConnectionRpc } from '../rpc.ts' /** Required services (none — this is the wire root). */ @@ -51,6 +54,8 @@ export interface ConnectionHandle { readonly api: IApiClient /** Whether the current page authority is loopback; non-browser contexts default to true. */ readonly isLoopback: boolean + /** Generic logical RPC channels over the same Connection transport. */ + readonly rpc: ClientConnectionRpc /** * Start the connect/pump/reconnect loop with the consumer's frame sinks. * One consumer owns the streams (the runtime object layer); a second call @@ -70,10 +75,12 @@ export function apply(ctx: Context): void { const pageLocation = typeof location === 'undefined' ? undefined : location const fixture = pageLocation !== undefined && new URLSearchParams(pageLocation.search).has('fixture') const api: IApiClient = fixture ? new FixtureApiClient() : new WebApiClient() + const rpc = fixture ? createUnavailableConnectionRpc() : createWebConnectionRpc() let started = false const handle: ConnectionHandle = { api, isLoopback: pageLocation === undefined || isLoopbackHostname(pageLocation.hostname), + rpc, start(sinks, config) { if (started) throw new Error('connection: the stream loop is already owned by another consumer') started = true diff --git a/packages/client/connection/src/client/rpc.ts b/packages/client/connection/src/client/rpc.ts new file mode 100644 index 0000000000..36e16426b2 --- /dev/null +++ b/packages/client/connection/src/client/rpc.ts @@ -0,0 +1,75 @@ +/** Browser caller for generic Connection unary RPC channels. */ + +import { + RpcId, + serverResponseSchema, + type ClientRequest, +} from '@deepseek-ai/dsh-host-apiproxy/api' +import type { ClientConnectionRpc } from '../rpc.ts' + +const INTERNAL_BASE = 'http://dsh.internal' +const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/ +const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/ + +/** + * Create the browser-backed generic RPC caller. + * @returns caller that owns request correlation and response-envelope validation. + */ +export function createWebConnectionRpc(): ClientConnectionRpc { + return { + async call(channel, endpoint, payload, signal) { + assertTarget(channel, endpoint) + const rpcId = RpcId(crypto.randomUUID()) + const message: ClientRequest = { + type: 'client-request', + rpcId, + method: endpoint, + payload, + } + const response = await globalThis.fetch( + new URL(`${channel}/${endpoint}`, resolveBase()), + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(message), + ...signal === undefined ? {} : { signal }, + }, + ) + if (!response.ok) { + throw new Error(`transport failure for ${channel}/${endpoint}: HTTP ${response.status}`) + } + const full = serverResponseSchema.parse(await response.json()) + if (full.rpcId !== rpcId) { + throw new Error(`rpcId mismatch for ${endpoint}: sent ${rpcId}, got ${full.rpcId}`) + } + return full.result + }, + } +} + +/** + * Create the fixture-mode caller, where no Host Remote registry exists. + * @returns caller that rejects every generic Remote invocation. + */ +export function createUnavailableConnectionRpc(): ClientConnectionRpc { + return { + call(channel, endpoint) { + return Promise.reject(new Error(`connection RPC ${channel}/${endpoint} is unavailable in fixture mode`)) + }, + } +} + +function resolveBase(): string { + const location = (globalThis as { location?: { origin?: string } }).location + return location?.origin !== undefined && location.origin !== 'null' ? location.origin : INTERNAL_BASE +} + +function assertTarget(channel: string, endpoint: string): void { + const segments = endpoint.split('/') + if (!CHANNEL_PATTERN.test(channel) + || segments.length === 0 + || segments.some(segment => + segment === '' || segment === '.' || segment === '..' || !ENDPOINT_SEGMENT_PATTERN.test(segment))) { + throw new Error(`connection: invalid RPC target ${JSON.stringify(`${channel}/${endpoint}`)}`) + } +} diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 2e27a78d70..d8b6ef8846 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -7,15 +7,25 @@ import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' import { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts' import { bridge } from './http-bridge.ts' import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts' +import { HostConnectionService } from './rpc-host.ts' import { rejectWebSocketUpgrade, WebSocketDownlinks } from './websocket-downlink.ts' +export type { + ConnectionRpcAuthority, + ConnectionRpcHandler, + ConnectionRpcHandlerOptions, + HostConnectionHandle, + HostConnectionRpc, +} from './rpc.ts' +export { HostConnectionService } from './rpc-host.ts' + export { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts' /** Stable Cordis plugin name. */ export const name = 'client-connection' -/** Services required before mounting the route. */ -export const inject = ['httpServer', 'apiProxy'] +/** Services required before providing Connection; legacy `/api` attaches when apiProxy is present. */ +export const inject = ['httpServer'] /** Plugin config: the deployment's non-loopback serving authorities. */ export interface ConnectionConfig { @@ -83,49 +93,52 @@ export function apply(ctx: Context, config?: ConnectionConfig): void { // Config boundary: a malformed entry fails the load loudly here rather than // silently authorizing its hostname prefix at request time. for (const entry of trustedHosts) assertTrustedAuthority(entry) - const apiHandler = toFetchHandler(ctx.apiProxy) - const downlinks = new WebSocketDownlinks(ctx.apiProxy) - const route: WebRoute = { - kind: 'prefix', - path: API_PATH, - handler: async (req, res) => { - const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname - const method = pathname.startsWith(`${API_PATH}/`) - ? pathname.slice(API_PATH.length + 1) - : undefined - const allowed = method !== undefined && PRIVILEGED_METHODS.has(method) - ? isTrustedApiRequest(req, []) - : isTrustedApiRequest(req, trustedHosts) - if (!allowed) { - res.writeHead(403) - res.end('forbidden') - return - } - if (req.method === 'GET' && (pathname === MUX_EVENTS_PATH || pathname === HOST_EVENTS_PATH)) { - res.writeHead(426, { connection: 'Upgrade', upgrade: 'websocket' }) - res.end('upgrade required') - return - } - await bridge(req, res, apiHandler) - }, - } - ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route') - const registerDownlink = ( - path: string, - handle: WebUpgradeRoute['handler'], - ): void => { - ctx.effect(() => ctx.httpServer.registerUpgrade({ - path, - handler: (req, socket, head) => { - if (!isTrustedApiRequest(req, trustedHosts)) { - rejectWebSocketUpgrade(socket) + new HostConnectionService(ctx, trustedHosts) + ctx.inject(['apiProxy'], (apiCtx) => { + const apiHandler = toFetchHandler(apiCtx.apiProxy) + const downlinks = new WebSocketDownlinks(apiCtx.apiProxy) + const route: WebRoute = { + kind: 'prefix', + path: API_PATH, + handler: async (req, res) => { + const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname + const method = pathname.startsWith(`${API_PATH}/`) + ? pathname.slice(API_PATH.length + 1) + : undefined + const allowed = method !== undefined && PRIVILEGED_METHODS.has(method) + ? isTrustedApiRequest(req, []) + : isTrustedApiRequest(req, trustedHosts) + if (!allowed) { + res.writeHead(403) + res.end('forbidden') return } - return handle(req, socket, head) + if (req.method === 'GET' && (pathname === MUX_EVENTS_PATH || pathname === HOST_EVENTS_PATH)) { + res.writeHead(426, { connection: 'Upgrade', upgrade: 'websocket' }) + res.end('upgrade required') + return + } + await bridge(req, res, apiHandler) }, - }), `client-connection: ${path} WebSocket`) - } - ctx.effect(() => () => downlinks.close(), 'client-connection: WebSocket downlinks') - registerDownlink(MUX_EVENTS_PATH, (req, socket, head) => { downlinks.handleMux(req, socket, head) }) - registerDownlink(HOST_EVENTS_PATH, (req, socket, head) => { downlinks.handleHost(req, socket, head) }) + } + apiCtx.effect(() => apiCtx.httpServer.register(route), 'client-connection: /api route') + const registerDownlink = ( + path: string, + handle: WebUpgradeRoute['handler'], + ): void => { + apiCtx.effect(() => apiCtx.httpServer.registerUpgrade({ + path, + handler: (req, socket, head) => { + if (!isTrustedApiRequest(req, trustedHosts)) { + rejectWebSocketUpgrade(socket) + return + } + return handle(req, socket, head) + }, + }), `client-connection: ${path} WebSocket`) + } + apiCtx.effect(() => () => downlinks.close(), 'client-connection: WebSocket downlinks') + registerDownlink(MUX_EVENTS_PATH, (req, socket, head) => { downlinks.handleMux(req, socket, head) }) + registerDownlink(HOST_EVENTS_PATH, (req, socket, head) => { downlinks.handleHost(req, socket, head) }) + }) } diff --git a/packages/client/connection/src/rpc-host.ts b/packages/client/connection/src/rpc-host.ts new file mode 100644 index 0000000000..be9eedca8f --- /dev/null +++ b/packages/client/connection/src/rpc-host.ts @@ -0,0 +1,150 @@ +/** Host registry and HTTP adapter for generic Connection RPC channels. */ + +import { Context, Service } from 'cordis' +import type { WebRoute } from '@deepseek-ai/dsh-host-webserver' +import { + clientRequestSchema, + RpcId, + type ClientRequest, + type RpcError, + type RpcId as RpcIdType, + type ServerResponse as RpcServerResponse, +} from '@deepseek-ai/dsh-host-apiproxy/api' +import { bridge } from './http-bridge.ts' +import { isTrustedApiRequest } from './api-request-trust.ts' +import type { + ConnectionRpcHandler, + ConnectionRpcHandlerOptions, + HostConnectionHandle, + HostConnectionRpc, +} from './rpc.ts' + +const INVALID_REQUEST_RPC_ID = RpcId('invalid-request') +const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/ +const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/ + +/** Host Connection service whose channel registrations belong to the caller fiber. */ +export class HostConnectionService extends Service implements HostConnectionHandle { + /** + * Provide the Host half over the active HTTP server. + * @param ctx - owning Connection plugin context. + * @param trustedHosts - deployment authorities accepted by trusted-host channels. + */ + constructor(ctx: Context, private readonly trustedHosts: readonly string[]) { + super(ctx, 'connection') + } + + /** Generic channel registry scoped to the Context reading this service. */ + get rpc(): HostConnectionRpc { + const owner = this.ctx + return { + handle: (channel, handler, options) => this.register(owner, channel, handler, options), + } + } + + private register( + owner: Context, + channel: string, + handler: ConnectionRpcHandler, + options: ConnectionRpcHandlerOptions, + ): () => Promise { + assertChannel(channel) + const trustedHosts = options.authority === 'loopback' ? [] : this.trustedHosts + const fetchHandler = rpcFetchHandler(channel, handler) + const route: WebRoute = { + kind: 'prefix', + path: channel, + handler: async (req, res) => { + if (!isTrustedApiRequest(req, trustedHosts)) { + res.writeHead(403) + res.end('forbidden') + return + } + await bridge(req, res, fetchHandler) + }, + } + return owner.effect( + () => owner.httpServer.register(route), + `client-connection: ${channel} rpc channel`, + ) + } +} + +function rpcFetchHandler( + channel: string, + handler: ConnectionRpcHandler, +): { fetch: typeof fetch } { + return { + async fetch(input: RequestInfo | URL, init?: RequestInit): Promise { + const request = input instanceof Request ? input : new Request(input, init) + const endpoint = endpointFromPath(channel, new URL(request.url).pathname) + if (request.method !== 'POST' || endpoint === undefined) { + return new Response('not found', { status: 404 }) + } + + const mediaType = request.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 request.json() + } catch { + return new Response('body is not JSON', { status: 400 }) + } + + const envelope = clientRequestSchema.safeParse(body) + if (!envelope.success) { + 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 !== endpoint) { + return errorResponse(message.rpcId, { + code: 'bad-request', + message: `method ${JSON.stringify(message.method)} does not match endpoint ${JSON.stringify(endpoint)}`, + details: { issues: [] }, + }) + } + + try { + const result = await handler(endpoint, message.payload, request.signal) + return fullResponse(message.rpcId, result) + } catch (error) { + return new Response(`handler failure: ${String(error)}`, { status: 500 }) + } + }, + } +} + +function endpointFromPath(channel: string, pathname: string): string | undefined { + if (!pathname.startsWith(`${channel}/`)) return undefined + const endpoint = pathname.slice(channel.length + 1) + const segments = endpoint.split('/') + if (segments.length === 0 || segments.some(segment => + segment === '' || segment === '.' || segment === '..' || !ENDPOINT_SEGMENT_PATTERN.test(segment))) { + return undefined + } + return endpoint +} + +function errorResponse(rpcId: RpcIdType, error: RpcError): Response { + return fullResponse(rpcId, { ok: false, error }) +} + +function fullResponse(rpcId: RpcIdType, result: RpcServerResponse['result']): Response { + const body: RpcServerResponse = { type: 'server-response', rpcId, result } + return Response.json(body) +} + +function assertChannel(channel: string): void { + if (!CHANNEL_PATTERN.test(channel) || channel === '/api') { + throw new Error(`connection: invalid or reserved RPC channel ${JSON.stringify(channel)}`) + } +} diff --git a/packages/client/connection/src/rpc.ts b/packages/client/connection/src/rpc.ts new file mode 100644 index 0000000000..ab68783724 --- /dev/null +++ b/packages/client/connection/src/rpc.ts @@ -0,0 +1,59 @@ +/** Generic unary RPC contracts shared by the Host and Client Connection halves. */ + +import type { RpcResult } from '@deepseek-ai/dsh-host-apiproxy/api' + +/** Trust fence applied before a Host RPC channel reaches its handler. */ +export type ConnectionRpcAuthority = 'trusted-host' | 'loopback' + +/** Registration policy for one logical RPC channel. */ +export interface ConnectionRpcHandlerOptions { + /** Browser authority accepted by every endpoint in this channel. */ + readonly authority: ConnectionRpcAuthority +} + +/** Handler invoked after Connection has decoded the transport envelope. */ +export type ConnectionRpcHandler = ( + endpoint: string, + payload: unknown, + signal: AbortSignal, +) => Promise> + +/** Host registry for logical RPC channels carried by the current transport. */ +export interface HostConnectionRpc { + /** + * Register one absolute channel prefix and its trust policy. + * @param channel - absolute logical channel such as `/api2`. + * @param handler - decoded endpoint handler returning the existing RPC result shape. + * @param options - channel trust policy. + * @returns asynchronous disposer removing the channel and its physical route. + */ + handle( + channel: string, + handler: ConnectionRpcHandler, + options: ConnectionRpcHandlerOptions, + ): () => Promise +} + +/** Host `ctx.connection` shape consumed by transport-independent adapters. */ +export interface HostConnectionHandle { + /** Generic RPC channel registry. */ + readonly rpc: HostConnectionRpc +} + +/** Client caller for logical RPC channels carried by the current transport. */ +export interface ClientConnectionRpc { + /** + * Call one endpoint through an already registered logical channel. + * @param channel - absolute logical channel such as `/api2`. + * @param endpoint - channel-relative endpoint such as `goals/create`. + * @param payload - channel-owned request payload. + * @param signal - optional caller cancellation. + * @returns the existing RPC success/error result; correlation stays inside Connection. + */ + call( + channel: string, + endpoint: string, + payload: unknown, + signal?: AbortSignal, + ): Promise> +} diff --git a/packages/client/connection/tests/client-apply.spec.ts b/packages/client/connection/tests/client-apply.spec.ts index 524983fb4f..d93844a2b8 100644 --- a/packages/client/connection/tests/client-apply.spec.ts +++ b/packages/client/connection/tests/client-apply.spec.ts @@ -203,4 +203,41 @@ describe('connection client apply', () => { expect(sockets).toHaveLength(1) expect(sockets[0]?.readyState).toBe(FakeWebSocket.CLOSED) }) + + it('carries generic RPC calls over the isolated channel with rpcId echo validation', async () => { + ;(globalThis as Win).location = { hostname: 'localhost', search: '' } + const handle = await mount() + const original = globalThis.fetch + const seen: { url: string; body: unknown }[] = [] + globalThis.fetch = async (input: URL | RequestInfo, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + if (typeof init?.body !== 'string') throw new TypeError('expected a JSON string request body') + const body = JSON.parse(init.body) as { rpcId: string } + seen.push({ url, body }) + return Response.json({ + type: 'server-response', + rpcId: body.rpcId, + result: { ok: true, value: { ref: 'goal-1' } }, + }) + } + try { + await expect(handle.rpc.call('/api2', 'goals/create', { args: { agentId: 'agent-1' } })) + .resolves.toEqual({ ok: true, value: { ref: 'goal-1' } }) + } finally { + globalThis.fetch = original + } + expect(seen).toHaveLength(1) + expect(seen[0]?.url).toBe('http://dsh.internal/api2/goals/create') + expect(seen[0]?.body).toMatchObject({ + type: 'client-request', + method: 'goals/create', + payload: { args: { agentId: 'agent-1' } }, + }) + }) + + it('keeps generic Remote calls unavailable in the client-only fixture', async () => { + ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } + const handle = await mount() + await expect(handle.rpc.call('/api2', 'goals/create', {})).rejects.toThrow(/unavailable in fixture mode/) + }) }) diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 3015881d2f..af85d4e510 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -7,8 +7,9 @@ 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 { RpcId, type ClientRequest } from '@deepseek-ai/dsh-host-apiproxy/api' import type { HttpServerService, WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' -import { API_PATH, apply, HOST_EVENTS_PATH, inject, MUX_EVENTS_PATH } from '../src/index.ts' +import { API_PATH, apply, HOST_EVENTS_PATH, inject, MUX_EVENTS_PATH, type HostConnectionHandle } from '../src/index.ts' /** Structural httpServer fake recording both route registries. */ function fakeHttpServer( @@ -17,6 +18,9 @@ function fakeHttpServer( ): Pick { return { register(route) { + if (routes.some(candidate => candidate.kind === route.kind && candidate.path === route.path)) { + throw new Error(`duplicate route ${route.path}`) + } routes.push(route) return () => { routes.splice(routes.indexOf(route), 1) } }, @@ -36,15 +40,25 @@ function fakeRequest(headers: Record, url = `${API_PATH}/session return request } +/** JSON POST carrying a complete client-request envelope. */ +function fakePost(headers: Record, url: string, body: unknown): IncomingMessage { + const request = Readable.from([Buffer.from(JSON.stringify(body))]) as unknown as IncomingMessage + Object.assign(request, { url, method: 'POST', headers: { 'content-type': 'application/json', ...headers } }) + return request +} + /** Response recorder compatible with both the fence's short-circuit and the bridge. */ function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown } } { const state: { status?: number; body?: unknown } = {} + const chunks: Buffer[] = [] const response = Object.assign(new EventEmitter(), { writableEnded: false, writeHead(value: number) { state.status = value; return this }, - write() { return true }, + write(value: string | Uint8Array) { chunks.push(Buffer.from(value)); return true }, end(this: { writableEnded: boolean }, value?: unknown) { - if (value !== undefined) state.body = value + if (typeof value === 'string' || value instanceof Uint8Array) chunks.push(Buffer.from(value)) + else if (value !== undefined) throw new TypeError('fake response only accepts string or Uint8Array bodies') + if (chunks.length > 0) state.body = Buffer.concat(chunks).toString() this.writableEnded = true return this }, @@ -173,6 +187,78 @@ describe('connection node half', () => { expect(declared.state.status).toBe(404) await dispose() }) + + it('provides a disposable generic RPC channel without requiring apiProxy', async () => { + const ctx = new Context() + const routes: WebRoute[] = [] + ctx.provide('httpServer', fakeHttpServer(routes, []) as HttpServerService) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(routes).toHaveLength(0) + + const connection = ctx.get('connection') as HostConnectionHandle + const calls: unknown[] = [] + const remove = connection.rpc.handle('/api2', async (endpoint, payload) => { + calls.push({ endpoint, payload }) + return { ok: true, value: { accepted: true } } + }, { authority: 'trusted-host' }) + const route = routes.find(candidate => candidate.path === '/api2') + expect(route).toBeDefined() + + const request: ClientRequest = { + type: 'client-request', + rpcId: RpcId('rpc-api2'), + method: 'goals/create', + payload: { args: { agentId: 'agent-1' } }, + } + const result = fakeResponse() + await route!.handler(fakePost({ host: '127.0.0.1:3080' }, '/api2/goals/create', request), result.response) + expect(result.state.status).toBe(200) + expect(JSON.parse(String(result.state.body))).toEqual({ + type: 'server-response', + rpcId: 'rpc-api2', + result: { ok: true, value: { accepted: true } }, + }) + expect(calls).toEqual([{ + endpoint: 'goals/create', + payload: { args: { agentId: 'agent-1' } }, + }]) + + expect(() => connection.rpc.handle('/api2', async () => ({ ok: true, value: null }), { + authority: 'trusted-host', + })).toThrow(/duplicate route/) + await remove() + expect(routes).toHaveLength(0) + await fiber.dispose() + }) + + it('applies the configured trust fence and JSON envelope checks to generic channels', async () => { + const ctx = new Context() + const routes: WebRoute[] = [] + ctx.provide('httpServer', fakeHttpServer(routes, []) as HttpServerService) + const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.example'] }) + await fiber.await() + const connection = ctx.get('connection') as HostConnectionHandle + const remove = connection.rpc.handle('/api2', async () => ({ ok: true, value: null }), { + authority: 'trusted-host', + }) + const route = routes[0]! + + const denied = fakeResponse() + await route.handler(fakePost({ host: 'other.example' }, '/api2/goals/create', {}), denied.response) + expect(denied.state).toMatchObject({ status: 403, body: 'forbidden' }) + + const badEnvelope = fakeResponse() + await route.handler(fakePost({ host: 'harness.example' }, '/api2/goals/create', { + type: 'client-request', rpcId: 'rpc-bad', method: 'other', payload: {}, + }), badEnvelope.response) + expect(JSON.parse(String(badEnvelope.state.body))).toMatchObject({ + rpcId: 'rpc-bad', + result: { ok: false, error: { code: 'bad-request' } }, + }) + await remove() + await fiber.dispose() + }) }) describe('connection node half over a real HTTP server', () => { diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index e5691a0619..14e51fae8e 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -27,6 +27,9 @@ async function mount(): Promise { const handle: ConnectionHandle = { api, isLoopback: true, + rpc: { + call: () => Promise.reject(new Error('unexpected generic RPC call')), + }, start: (sinks) => { bench.sinks = sinks return { stop: () => { bench.stopped += 1 } } diff --git a/packages/client/runtime/tests/wire-events.spec.ts b/packages/client/runtime/tests/wire-events.spec.ts index 21e7f1fc06..f081eb54c1 100644 --- a/packages/client/runtime/tests/wire-events.spec.ts +++ b/packages/client/runtime/tests/wire-events.spec.ts @@ -21,6 +21,9 @@ async function mount(): Promise { const handle: ConnectionHandle = { api, isLoopback: true, + rpc: { + call: () => Promise.reject(new Error('unexpected generic RPC call')), + }, start: (sinks) => { bench.sinks = sinks return { stop: () => {} } diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 1e45991080..74facbd69b 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -31,6 +31,9 @@ const CSS_VIRTUAL_SUFFIX = '.mjs' */ export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|brand)(\/|$)/ +/** Generated descriptor/codec contribution with no shared runtime identity. */ +const GENERATED_REMOTE = /^@deepseek-ai\/dsh-[a-z0-9]+(?:-[a-z0-9]+)*\/remote$/ + /** * Documented TEMPORARY exemption, not a platform module (hence not in * platform.ts): the snapshot-store engine (createSnapshotStore/defineStore/ @@ -126,9 +129,9 @@ export function clientBundle(id: string, libEntry: readonly string[]): [UserConf resolveId(source: string) { if (!source.startsWith('@deepseek-ai/')) return null if (CLIENT_EXTERNALS.includes(source)) return null // platform module: external wins - if (INLINE_SAFE.test(source)) return null // wire/type layer: inline is the point + if (INLINE_SAFE.test(source) || GENERATED_REMOTE.test(source)) return null // wire contribution: inline is the point throw new Error( - `client bundle purity: "${source}" is not a platform module (CLIENT_EXTERNALS) and not an inline-safe wire layer — ` + `client bundle purity: "${source}" is not a platform module (CLIENT_EXTERNALS), an inline-safe wire layer, or a generated /remote contribution — ` + 'cross-plugin value imports are forbidden; collaborate through cordis services (type-only imports are erased and never reach this gate)', ) }, diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 2bf1c1b2d9..b8da6049e8 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1118,11 +1118,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'typert', - summary: 'Registry of generated schemas and package reflection.', + summary: 'Registry of generated schemas, package reflection, invocations, and Remote dependency providers.', methods: [ { - signature: 'register(contribution: TypertContribution): () => void', - jsDoc: '/**\n * Register one generated contribution atomically for the calling fiber.\n * Duplicate package-face identities or schema keys reject the whole batch.\n * @param contribution - generated schemas and package metadata.\n * @returns the exact effect disposer that removes this contribution.\n */', + signature: 'register(contribution: TypertContribution): TypeRTDisposer', + jsDoc: '/**\n * Register one generated contribution atomically for the calling fiber.\n * Duplicate package-face identities, schemas, invocation ids, or endpoints\n * reject the whole batch.\n * @param contribution - generated schemas, reflection, and Host invocations.\n * @returns the exact effect disposer that removes this contribution.\n */', }, { signature: 'get(key: string): TypertSchemaRecord | undefined', @@ -1150,6 +1150,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'typertGateway', + summary: 'Resolve strict generated definitions or conservative SRC markers against current Cordis Services and TypeRT providers.', + methods: [ + { + signature: 'async invoke(request: InvokeRemoteRequest): Promise', + jsDoc: '/**\n * Invoke one live Remote method through strict generated reflection or SRC markers.\n * @param request - decoded endpoint and exact named wire arguments.\n * @returns the validated business result.\n * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; business errors retain their identity.\n */', + }, + ], + }, { key: 'userInteraction', summary: '`ctx.userInteraction`: one active UI provider plus an `ask()` surface.', @@ -2057,6 +2067,22 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'InvariantInstaller', declaration: 'export interface InvariantInstaller {\n (ctx: Context, fail: InvariantFailure): void | Promise;\n readonly inject?: Inject;\n}', }, + { + name: 'InvocationDescriptor', + declaration: 'export interface InvocationDescriptor {\n readonly id: string;\n readonly service: string;\n readonly namespace: string;\n readonly method: string;\n readonly implementation?: string;\n readonly invocation: {\n readonly kind: \'direct\';\n } | {\n readonly kind: \'context\';\n readonly context: string;\n readonly wire: string;\n readonly codec: TypeRTCodec;\n };\n readonly scope?: {\n readonly context: string;\n readonly wire: string;\n };\n readonly parameters: readonly InvocationParameterDescriptor[];\n readonly result: TypeRTCodec;\n readonly sourceLocation?: InvocationSourceLocation;\n}', + }, + { + name: 'InvocationParameterDescriptor', + declaration: 'export interface InvocationParameterDescriptor {\n readonly name: string;\n readonly wire: string;\n readonly source: \'json\' | \'lookup\';\n readonly lookup?: string;\n readonly codec: TypeRTCodec;\n}', + }, + { + name: 'InvocationSourceLocation', + declaration: 'export interface InvocationSourceLocation {\n readonly file: string;\n readonly line: number;\n readonly column: number;\n}', + }, + { + name: 'InvokeRemoteRequest', + declaration: 'export interface InvokeRemoteRequest {\n readonly namespace: string;\n readonly method: string;\n readonly args: Readonly>;\n}', + }, { name: 'JsonSchemaNode', 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}', @@ -3037,9 +3063,17 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'TurnEndReasonMap', declaration: 'export interface TurnEndReasonMap {\n completed: {\n kind: \'completed\';\n };\n aborted: {\n kind: \'aborted\';\n reason: TurnEndCancelCause;\n };\n blocked: {\n kind: \'blocked\';\n };\n error: {\n kind: \'error\';\n error: LlmFailure;\n };\n \'max-tokens\': {\n kind: \'max-tokens\';\n };\n interrupted: {\n kind: \'interrupted\';\n };\n}', }, + { + name: 'TypeRTCodec', + declaration: 'export type TypeRTCodec = {\n readonly mode: \'strict\';\n readonly typeSymbol: string;\n readonly schema: TypeRTSchema;\n} | {\n readonly mode: \'src-json\';\n};', + }, { name: 'TypertContribution', - declaration: 'export interface TypertContribution {\n readonly package: string;\n readonly face: TypertFace;\n readonly schemas: readonly TypertSchema[];\n readonly model: TypertPackageModel;\n}', + declaration: 'export interface TypertContribution {\n readonly package: string;\n readonly face: TypertFace;\n readonly schemas: readonly TypertSchema[];\n readonly model: TypertPackageModel;\n readonly invocations?: readonly InvocationDescriptor[];\n}', + }, + { + name: 'TypeRTDisposer', + declaration: 'export type TypeRTDisposer = () => Promise;', }, { name: 'TypertDocTag', @@ -3077,6 +3111,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'TypertSchema', declaration: 'export interface TypertSchema {\n readonly name: string;\n readonly schema: z.ZodType;\n}', }, + { + name: 'TypeRTSchema', + declaration: 'export interface TypeRTSchema {\n parse(value: unknown): Output;\n}', + }, { name: 'TypertSchemaFilter', declaration: 'export interface TypertSchemaFilter {\n readonly package?: string;\n readonly face?: TypertFace;\n}', diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index 2e204bc7f0..9f64d33e75 100644 --- a/packages/core/agent/package.json +++ b/packages/core/agent/package.json @@ -15,12 +15,17 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", + "lib/types/**/*.js", "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", @@ -30,6 +35,7 @@ "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", + "@deepseek-ai/dsh-type-meta": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { @@ -38,6 +44,8 @@ "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", + "@deepseek-ai/dsh-typert-registry": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 55cb94d8f9..8f316f75dc 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -12,6 +12,7 @@ import { isPromise } from 'node:util/types' import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import type { TypeRTContext, TypeRTLookup } from '@deepseek-ai/dsh-type-meta' import type { Agent, AgentOptions } from './types.ts' export * from './types.ts' @@ -20,6 +21,16 @@ export * from './llm-target.ts' export { agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from './dispatch.ts' export type { AgentEventDispatch, AgentSubjectEvent } from './dispatch.ts' +declare module '@deepseek-ai/dsh-type-meta' { + interface TypeRTLookupMap { + agent: TypeRTLookup + } + + interface TypeRTContextMap { + agent: TypeRTContext + } +} + declare module 'cordis' { interface Context { agents: AgentRegistry @@ -251,6 +262,20 @@ export class AgentRegistry extends Service { constructor(ctx: Context) { super(ctx, 'agents') + ctx.inject(['typert'], (typeCtx) => { + typeCtx.typert.lookups.register('agent', { + parameter: 'agent', + wire: 'agentId', + hostTypeSymbol: '@deepseek-ai/dsh-agent#Agent', + wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + resolve: sessionId => this.get(sessionId), + }) + typeCtx.typert.contexts.registerHost('agent', { + wire: 'agentId', + wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + resolve: sessionId => this.get(sessionId)?.ctx, + }) + }) // The `ctx.agent` DX accessor: default `undefined` on every context, so a // plain plugin context reads cleanly instead of hitting the Cordis // unknown-property throw. Each Agent.ctx shadows it with an own property diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index e80d575aeb..643a3a49a6 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -6,6 +6,7 @@ import AgentRegistry, { agentEvents, Inbox, } from '@deepseek-ai/dsh-agent' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import type { Agent, @@ -142,6 +143,31 @@ describe('Inbox', () => { }) describe('AgentRegistry', () => { + it('contributes Agent lookup and scoped Context providers while TypeRT is live', async () => { + const ctx = new Context() + const agentFiber = ctx.plugin(AgentRegistry) + await agentFiber + await ctx.plugin(TypertRegistry) + const agent = stubAgent('remote-agent') + const disposeAgent = ctx.agents.register(agent) + + const lookup = ctx.typert.lookups.get('agent') + expect(lookup).toMatchObject({ + parameter: 'agent', + wire: 'agentId', + hostTypeSymbol: '@deepseek-ai/dsh-agent#Agent', + wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + }) + expect(lookup?.resolve(agent.id)).toBe(agent) + expect(ctx.typert.contexts.getHost('agent')?.resolve(agent.id)).toBe(agent.ctx) + + disposeAgent() + expect(lookup?.resolve(agent.id)).toBeUndefined() + await agentFiber.dispose() + expect(ctx.typert.lookups.get('agent')).toBeUndefined() + expect(ctx.typert.contexts.getHost('agent')).toBeUndefined() + }) + it('registers exact entries, emits lifecycle events, and unregisters on owner disposal', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent/tsconfig.json b/packages/core/agent/tsconfig.json index 1561175ed9..31d38b6017 100644 --- a/packages/core/agent/tsconfig.json +++ b/packages/core/agent/tsconfig.json @@ -28,6 +28,9 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../typert/type-meta" } ] } diff --git a/packages/core/session/package.json b/packages/core/session/package.json index 83be69528e..04aa221573 100644 --- a/packages/core/session/package.json +++ b/packages/core/session/package.json @@ -38,6 +38,7 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", + "@deepseek-ai/dsh-type-meta": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { @@ -45,6 +46,8 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", + "@deepseek-ai/dsh-typert-registry": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index d250998624..3f73242958 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -13,6 +13,7 @@ import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import type { Message } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION, SessionId } from './types.ts' +import type { TypeRTLookup } from '@deepseek-ai/dsh-type-meta' import type { CreateSessionOptions, EpochHeader, PrepareSessionOptions, RequestContext, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts' import { snapshotJsonValue } from './json.ts' import { deriveEventMessage, SurfaceManager } from './surface.ts' @@ -105,6 +106,12 @@ declare module 'cordis' { } } +declare module '@deepseek-ai/dsh-type-meta' { + interface TypeRTLookupMap { + session: TypeRTLookup + } +} + /** Validate and freeze one detached creation header in place. */ function validateSessionHeader(id: SessionId, input: unknown): SessionHeader { if (input === null || typeof input !== 'object' || Array.isArray(input)) { @@ -803,6 +810,15 @@ export class SessionStore extends Service { constructor(ctx: Context) { super(ctx, 'sessions') + ctx.inject(['typert'], (typeCtx) => { + typeCtx.typert.lookups.register('session', { + parameter: 'session', + wire: 'sessionId', + hostTypeSymbol: '@deepseek-ai/dsh-session#Session', + wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + resolve: sessionId => this.get(sessionId), + }) + }) } /** diff --git a/packages/core/session/tests/typert.spec.ts b/packages/core/session/tests/typert.spec.ts new file mode 100644 index 0000000000..e1e2b32d68 --- /dev/null +++ b/packages/core/session/tests/typert.spec.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' + +describe('Session TypeRT provider', () => { + it('contributes live Session lookup in either service load order', async () => { + const ctx = new Context() + const sessionFiber = ctx.plugin(SessionStore) + await sessionFiber + await ctx.plugin(TypertRegistry) + const session = ctx.sessions.create(SessionId('remote-session')) + + const lookup = ctx.typert.lookups.get('session') + expect(lookup).toMatchObject({ + parameter: 'session', + wire: 'sessionId', + hostTypeSymbol: '@deepseek-ai/dsh-session#Session', + wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + }) + expect(lookup?.resolve(session.id)).toBe(session) + + await sessionFiber.dispose() + expect(ctx.typert.lookups.get('session')).toBeUndefined() + }) +}) diff --git a/packages/core/session/tsconfig.json b/packages/core/session/tsconfig.json index 253a1c8793..076ff73d9f 100644 --- a/packages/core/session/tsconfig.json +++ b/packages/core/session/tsconfig.json @@ -25,6 +25,9 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../typert/type-meta" } ] } diff --git a/packages/host/api-gateway/README.i18n.yaml b/packages/host/api-gateway/README.i18n.yaml new file mode 100644 index 0000000000..2abe47e0d3 --- /dev/null +++ b/packages/host/api-gateway/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/host/api-gateway/README.md +README.md: 3ef926ace2ee4d6008b1d6c18b1e070fa39bc176 +README.zh.md: 77b8b8a87d5f511000aac5cf9f75ebca5fcdfbca diff --git a/packages/host/api-gateway/README.md b/packages/host/api-gateway/README.md new file mode 100644 index 0000000000..3ef926ace2 --- /dev/null +++ b/packages/host/api-gateway/README.md @@ -0,0 +1,36 @@ +# @deepseek-ai/dsh-host-api-gateway + +English | [中文](README.zh.md) + +Two-sided Remote control for Host and Client Cordis environments. The Host entry provides `ctx.typertGateway`, while `@deepseek-ai/dsh-host-api-gateway/client` provides `ctx.api`; both consume the same generated `InvocationDescriptor` contract and leave transport, request correlation, trust, and response envelopes to Connection. + +## Host service: `TypertGatewayService` (ctx key: `typertGateway`) + +`ctx.typertGateway.invoke()` resolves the current descriptor and Cordis Service for each call, validates exact named arguments, resolves registered object or Context identities, invokes the public business method, and validates its result. Business Services declare participation with `bindTypeRTGateway()` and `@Remote` or `@RemoteContext` from [`dsh-type-meta`](../../typert/type-meta/README.md). + +Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use registered `ctx.typert.lookups` providers, while `@RemoteContext` resolves its receiver through a registered Host Context provider. 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 the trusted-host `/api2` unary RPC channel when Connection is available. Direct `invoke()` calls preserve business errors; `TypertGatewayError` distinguishes failures owned by dispatch, binding, providers, lookup, Context, arguments, and codecs. + +## Client service: `ClientApi` (ctx key: `api`) + +`ctx.api.mount()` validates and registers a generated Host-for-Client contribution, then installs concrete direct and scoped methods for the calling Cordis fiber. Duplicate endpoints, namespace collisions, and descriptors without strict generated codecs fail before methods become callable. + +Each call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api2', endpoint, ...)`. The returned value is validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls, and makes retained method handles reject. + +Generated declaration merges provide the TypeScript API. 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. + +## Model Experience + +None, as the package dispatches application calls and registers no prompt, tool, or session event. + +#### KV Cache effect + +No direct effect; invoked business Services own any model-visible result. + +## Known Limitations and Deferred Work + +- The Connection adapter currently maps dispatch and business failures to the RPC `internal` code with empty details. Structured `TypertGatewayError` categories remain available only to same-process callers. +- SRC mode supports unique identifier parameters without destructuring, defaults, or rest parameters. It validates JSON safety rather than generated business types and never infers optional fields. +- Only strict generated contributions can mount on the Client face. SRC markers have no Client codec or type projection. +- The package dispatches unary methods only. Incremental Session data uses a separate named-stream protocol over the same Connection. diff --git a/packages/host/api-gateway/README.zh.md b/packages/host/api-gateway/README.zh.md new file mode 100644 index 0000000000..77b8b8a87d --- /dev/null +++ b/packages/host/api-gateway/README.zh.md @@ -0,0 +1,36 @@ +# @deepseek-ai/dsh-host-api-gateway + +[English](README.md) | 中文 + +为 Host 与 Client 两侧的 Cordis 环境提供 Remote 控制。Host 入口提供 `ctx.typertGateway`,`@deepseek-ai/dsh-host-api-gateway/client` 则提供 `ctx.api`;两者使用同一份生成的 `InvocationDescriptor` 契约,并将传输、请求关联、信任和响应封装交由 Connection 处理。 + +## Host 服务:`TypertGatewayService`(ctx key:`typertGateway`) + +每次调用时,`ctx.typertGateway.invoke()` 都会解析当前的描述符和 Cordis 服务,校验具名参数是否完全匹配,解析已注册的对象或 Context 身份标识,调用公开的业务方法,并校验其结果。业务服务调用 `bindTypeRTGateway()` 并使用 [`dsh-type-meta`](../../typert/type-meta/README.md) 提供的 `@Remote` 或 `@RemoteContext` 装饰器,以显式声明接入。 + +严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用已向 `ctx.typert.lookups` 注册的提供方,`@RemoteContext` 则通过已注册的 Host Context 提供方解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。 + +Connection 可用时,Host 入口会注册 trusted-host 的 `/api2` 一元 RPC 通道。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 可区分分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。 + +## Client 服务:`ClientApi`(ctx key:`api`) + +`ctx.api.mount()` 会校验并注册生成的 Host-for-Client 贡献项,然后为发起调用的 Cordis fiber 安装具体的直接方法和作用域方法。重复端点、命名空间冲突,以及缺少生成的严格编解码器的描述符,都会在方法可调用前报错。 + +每次调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api2', endpoint, ...)` 发送。返回值经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用,并使外部仍持有的方法句柄在调用时返回拒绝。 + +生成的声明合并提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。 + +## 模型体验 + +无,因为该包分发应用调用,不注册任何提示词、工具或会话事件。 + +#### KV Cache 影响 + +无直接影响;被调用的业务服务负责产生任何模型可见结果。 + +## 已知限制与延期工作 + +- Connection 适配器目前将分发故障和业务故障映射为 RPC 的 `internal` 代码,且不附带详细信息。结构化的 `TypertGatewayError` 类别仅供同进程调用方使用。 +- SRC 模式仅支持名称唯一的标识符参数,不支持解构、默认值或剩余参数。它只校验值能否安全表示为 JSON,不校验生成的业务类型,也绝不会推断可选字段。 +- Client 侧只能挂载严格模式生成的贡献项。SRC 标记不具备 Client 编解码器或类型投影。 +- 该包只分发一元方法。增量会话数据通过同一个 Connection 上独立的具名流协议传输。 diff --git a/packages/host/api-gateway/package.json b/packages/host/api-gateway/package.json new file mode 100644 index 0000000000..3f3c905f1d --- /dev/null +++ b/packages/host/api-gateway/package.json @@ -0,0 +1,68 @@ +{ + "name": "@deepseek-ai/dsh-host-api-gateway", + "description": "Host dispatcher and Client API for TypeRT Remote invocations", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dshClient": { + "inject": [ + "@deepseek-ai/dsh-typert-registry", + "@deepseek-ai/dsh-client-connection" + ], + "platform": "web", + "immediately": true + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "dependencies": { + "@deepseek-ai/dsh-type-meta": "workspace:^" + }, + "peerDependencies": { + "@deepseek-ai/dsh-client-connection": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-typert-registry": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-host-webserver": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-typert-registry": "workspace:^", + "cordis": "^4.0.0-rc.7", + "zod": "^4.4.3" + } +} diff --git a/packages/host/api-gateway/src/client/index.ts b/packages/host/api-gateway/src/client/index.ts new file mode 100644 index 0000000000..57116db2cf --- /dev/null +++ b/packages/host/api-gateway/src/client/index.ts @@ -0,0 +1,370 @@ +/** + * Client projection of generated TypeRT Remote descriptors. Contributions + * install concrete namespace methods; no JavaScript Proxy participates in + * lookup, invocation, or type exposure. + */ + +import { Service } from 'cordis' +import type { Context } from 'cordis' +import type { ConnectionHandle, RpcError } from '@deepseek-ai/dsh-client-connection/client' +import type { + InvocationDescriptor, + TypeRTCodec, + TypeRTDisposer, + TypeRTRemoteContribution, + TypeRTRemoteNamespaceMap, +} from '@deepseek-ai/dsh-type-meta' + +type RemoteMethod = (...args: unknown[]) => Promise + +interface MountToken { + active: boolean + readonly abort: AbortController +} + +interface DirectNamespaceRecord { + readonly value: Record + readonly tokens: Map +} + +interface ScopedNamespaceRecord { + readonly service: ScopedRemoteNamespace + readonly tokens: Map +} + +interface ScopedProjection { + readonly context: string + readonly wire: string + readonly codec: TypeRTCodec + readonly parameterIndex?: number +} + +/** Typed API service augmented by generated direct Remote namespaces. */ +export interface ClientApi extends TypeRTRemoteNamespaceMap { + /** + * Mount one generated Host-for-Client contribution in the caller's fiber. + * @param contribution - explicitly selected Remote package artifact. + * @returns disposer withdrawing descriptors and concrete methods together. + */ + mount(contribution: TypeRTRemoteContribution): TypeRTDisposer +} + +declare module 'cordis' { + interface Context { + /** Generated direct Remote namespaces selected by the Client assembly. */ + api: ClientApi + } +} + +/** Required Client services: the TypeRT registry and the existing Connection carrier. */ +export const inject = ['typert', 'connection'] + +/** + * Install the typed Client API service. + * @param ctx - Client Cordis root. + */ +export function apply(ctx: Context): void { + new ClientApiService(ctx) +} + +class ClientApiService extends Service implements ClientApi { + private readonly ownerCtx: Context + private readonly direct = new Map() + private readonly scoped = new Map() + + constructor(ctx: Context) { + super(ctx, 'api') + this.ownerCtx = ctx + } + + mount(contribution: TypeRTRemoteContribution): TypeRTDisposer { + this.validateContribution(contribution) + const callerCtx = this.ctx + const disposeRemote = callerCtx.typert.remotes.register(contribution) + let disposeMethods: () => void | Promise + try { + disposeMethods = callerCtx.effect(() => { + const installed = contribution.descriptors.map(descriptor => this.install(descriptor)) + return () => { + for (const dispose of installed.reverse()) dispose() + } + }, `api-gateway.client.mount(${JSON.stringify(contribution.package)})`) + } catch (error) { + disposeRemote().catch(() => {}) + throw error + } + return async () => { + await Promise.all([disposeMethods(), disposeRemote()]) + } + } + + private validateContribution(contribution: TypeRTRemoteContribution): void { + const direct = new Map>() + const scoped = new Map>() + const add = ( + table: Map>, + descriptor: InvocationDescriptor, + kind: 'direct' | 'scoped', + ): void => { + const methods = table.get(descriptor.namespace) ?? new Set() + if (methods.has(descriptor.method)) { + throw new Error(`client api: contribution repeats ${kind} method ${endpointOf(descriptor)}`) + } + methods.add(descriptor.method) + table.set(descriptor.namespace, methods) + const live = kind === 'direct' + ? this.direct.get(descriptor.namespace)?.tokens + : this.scoped.get(descriptor.namespace)?.tokens + if (live?.has(descriptor.method) === true) { + throw new Error(`client api: ${kind} method ${endpointOf(descriptor)} is already mounted`) + } + } + for (const descriptor of contribution.descriptors) { + requireStrictDescriptor(descriptor) + if (descriptor.invocation.kind === 'direct') add(direct, descriptor, 'direct') + if (scopedProjection(descriptor) !== undefined) add(scoped, descriptor, 'scoped') + } + for (const namespace of direct.keys()) { + if (!this.direct.has(namespace) && namespace in this) { + throw new Error(`client api: namespace ${JSON.stringify(namespace)} conflicts with the API service`) + } + } + for (const [namespace, methods] of scoped) { + const record = this.scoped.get(namespace) + if (record !== undefined) { + for (const method of methods) record.service.assertMethodAvailable(method) + } else if (this.ownerCtx.reflect.props[namespace] !== undefined) { + throw new Error(`client api: scoped namespace ${JSON.stringify(namespace)} conflicts with an existing Context property`) + } + } + } + + private install(descriptor: InvocationDescriptor): () => void { + const token: MountToken = { active: true, abort: new AbortController() } + const installed: (() => void)[] = [] + if (descriptor.invocation.kind === 'direct') { + installed.push(this.installDirect(descriptor, token)) + } + const projection = scopedProjection(descriptor) + if (projection !== undefined) installed.push(this.installScoped(descriptor, projection, token)) + return () => { + if (!token.active) return + token.active = false + for (const dispose of installed.reverse()) dispose() + token.abort.abort() + } + } + + private installDirect(descriptor: InvocationDescriptor, token: MountToken): () => void { + let namespace = this.direct.get(descriptor.namespace) + if (namespace === undefined) { + namespace = { value: Object.create(null) as Record, tokens: new Map() } + this.direct.set(descriptor.namespace, namespace) + Object.defineProperty(this, descriptor.namespace, { + configurable: true, + enumerable: true, + value: namespace.value, + }) + } + namespace.tokens.set(descriptor.method, token) + Object.defineProperty(namespace.value, descriptor.method, { + configurable: true, + enumerable: true, + value: (...args: unknown[]) => this.invoke(descriptor, undefined, token, this.ownerCtx, args), + }) + return () => { + if (namespace.tokens.get(descriptor.method) !== token) return + Reflect.deleteProperty(namespace.value, descriptor.method) + namespace.tokens.delete(descriptor.method) + if (namespace.tokens.size !== 0) return + this.direct.delete(descriptor.namespace) + Reflect.deleteProperty(this, descriptor.namespace) + } + } + + private installScoped( + descriptor: InvocationDescriptor, + projection: ScopedProjection, + token: MountToken, + ): () => void { + let namespace = this.scoped.get(descriptor.namespace) + if (namespace === undefined) { + namespace = { + service: new ScopedRemoteNamespace( + this.ownerCtx, + descriptor.namespace, + (current, currentProjection, currentToken, caller, args) => + this.invoke(current, currentProjection, currentToken, caller, args), + ), + tokens: new Map(), + } + this.scoped.set(descriptor.namespace, namespace) + } + namespace.tokens.set(descriptor.method, token) + namespace.service.install(descriptor, projection, token) + return () => { + if (namespace.tokens.get(descriptor.method) !== token) return + namespace.service.remove(descriptor.method) + namespace.tokens.delete(descriptor.method) + } + } + + private async invoke( + descriptor: InvocationDescriptor, + projection: ScopedProjection | undefined, + token: MountToken, + callerCtx: Context, + values: readonly unknown[], + ): Promise { + const endpoint = endpointOf(descriptor) + if (!token.active) throw new Error(`client api: Remote method ${endpoint} is no longer mounted`) + const expected = descriptor.parameters.length - (projection?.parameterIndex === undefined ? 0 : 1) + if (values.length !== expected) { + throw new Error( + `client api: ${endpoint} expected ${String(expected)} argument(s), got ${String(values.length)}`, + ) + } + const args: Record = {} + if (projection !== undefined) { + const binder = this.ownerCtx.typert.contexts.getClient(projection.context) + if (binder === undefined) { + throw new Error(`client api: ${endpoint} has no Client Context binder for ${JSON.stringify(projection.context)}`) + } + const identity = binder.identity(callerCtx) + if (identity === undefined) { + throw new Error(`client api: ${endpoint} requires a ${JSON.stringify(projection.context)} Context`) + } + args[projection.wire] = parse(projection.codec, identity, endpoint, projection.wire) + } + let valueIndex = 0 + descriptor.parameters.forEach((parameter, parameterIndex) => { + if (parameterIndex === projection?.parameterIndex) return + args[parameter.wire] = parse(parameter.codec, values[valueIndex], endpoint, parameter.wire) + valueIndex += 1 + }) + const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined + if (connection === undefined) throw new Error(`client api: ${endpoint} has no active Connection`) + const result = await connection.rpc.call('/api2', endpoint, { args }, token.abort.signal) + if (!mountActive(token)) throw new Error(`client api: Remote method ${endpoint} was withdrawn during invocation`) + if (!result.ok) throw remoteFailure(endpoint, result.error) + return parse(descriptor.result, result.value, endpoint, 'result') + } +} + +type InvokeRemote = ( + descriptor: InvocationDescriptor, + projection: ScopedProjection, + token: MountToken, + callerCtx: Context, + args: readonly unknown[], +) => Promise + +class ScopedRemoteNamespace extends Service { + private readonly ownerCtx: Context + private readonly methods = new Set() + + constructor( + ctx: Context, + name: string, + private readonly invokeRemote: InvokeRemote, + ) { + super(ctx, name) + this.ownerCtx = ctx + } + + assertMethodAvailable(method: string): void { + if (method in this) { + throw new Error(`client api: scoped method ${JSON.stringify(`${this.name}/${method}`)} conflicts with its namespace service`) + } + } + + install(descriptor: InvocationDescriptor, projection: ScopedProjection, token: MountToken): void { + this.assertMethodAvailable(descriptor.method) + const method = descriptor.method + Object.defineProperty(this, method, { + configurable: true, + enumerable: true, + value: function (this: ScopedRemoteNamespace, ...args: unknown[]): Promise { + return this.invokeRemote(descriptor, projection, token, this.ctx, args) + }, + }) + this.methods.add(method) + if (this.methods.size === 1 && this.ownerCtx.get(this.name, false) === undefined) { + this.ownerCtx.set(this.name, this) + } + } + + remove(method: string): void { + Reflect.deleteProperty(this, method) + this.methods.delete(method) + if (this.methods.size === 0) this.ownerCtx.set(this.name, undefined) + } +} + +function endpointOf(descriptor: Pick): string { + return `${descriptor.namespace}/${descriptor.method}` +} + +function mountActive(token: MountToken): boolean { + return token.active +} + +function scopedProjection(descriptor: InvocationDescriptor): ScopedProjection | undefined { + if (descriptor.invocation.kind === 'context') { + return { + context: descriptor.invocation.context, + wire: descriptor.invocation.wire, + codec: descriptor.invocation.codec, + } + } + if (descriptor.scope === undefined) return undefined + const lookupParameters = descriptor.parameters + .map((parameter, index) => ({ parameter, index })) + .filter(candidate => candidate.parameter.source === 'lookup') + const selected = lookupParameters.length === 1 ? lookupParameters[0] : undefined + if (selected === undefined + || selected.parameter.wire !== descriptor.scope.wire + || selected.parameter.lookup !== descriptor.scope.context) { + throw new Error( + `client api: generated Remote ${endpointOf(descriptor)} scope must select its only lookup parameter`, + ) + } + return { + context: descriptor.scope.context, + wire: descriptor.scope.wire, + codec: selected.parameter.codec, + parameterIndex: selected.index, + } +} + +function requireStrictDescriptor(descriptor: InvocationDescriptor): void { + const endpoint = endpointOf(descriptor) + requireStrictCodec(descriptor.result, endpoint, 'result') + for (const parameter of descriptor.parameters) { + requireStrictCodec(parameter.codec, endpoint, parameter.wire) + } + if (descriptor.invocation.kind === 'context') { + requireStrictCodec(descriptor.invocation.codec, endpoint, descriptor.invocation.wire) + } +} + +function requireStrictCodec(codec: TypeRTCodec, endpoint: string, field: string): void { + if (codec.mode !== 'strict') { + throw new Error(`client api: generated Remote ${endpoint} field ${JSON.stringify(field)} has no strict codec`) + } +} + +function parse(codec: TypeRTCodec, value: unknown, endpoint: string, field: string): unknown { + if (codec.mode !== 'strict') { + throw new Error(`client api: generated Remote ${endpoint} field ${JSON.stringify(field)} has no strict codec`) + } + try { + return codec.schema.parse(value) + } catch (cause) { + throw new Error(`client api: ${endpoint} rejected ${JSON.stringify(field)}`, { cause }) + } +} + +function remoteFailure(endpoint: string, error: RpcError): Error { + return new Error(`client api: ${endpoint} failed: ${error.code}: ${error.message}`, { cause: error }) +} diff --git a/packages/host/api-gateway/src/index.ts b/packages/host/api-gateway/src/index.ts new file mode 100644 index 0000000000..ccb76e2d48 --- /dev/null +++ b/packages/host/api-gateway/src/index.ts @@ -0,0 +1,604 @@ +/** + * Live TypeRT Remote dispatch over Cordis Services and registered providers. + * Transport, request correlation, and response envelopes belong to Connection. + * @module @deepseek-ai/dsh-host-api-gateway + */ + +import { Context, Service, symbols } from 'cordis' +import { + remoteMethods, + type InvocationDescriptor, + type InvocationParameterDescriptor, + type TypeRTCodec, + type TypeRTGatewayBinding, + type TypeRTLookupProvider, +} from '@deepseek-ai/dsh-type-meta' +import type { + InvokeRemoteRequest, + TypertGateway, + TypertGatewayErrorCode, +} from './types.ts' + +export type { + InvokeRemoteRequest, + TypertGateway, + TypertGatewayErrorCode, +} from './types.ts' + +interface GatewayErrorOptions { + readonly cause?: unknown + readonly field?: string +} + +interface ResolvedBinding { + readonly binding: TypeRTGatewayBinding + readonly original: object +} + +type ConnectionRpcResult = + | { readonly ok: true; readonly value: unknown } + | { + readonly ok: false + readonly error: { + readonly code: 'internal' + readonly message: string + readonly details: Record + } + } + +interface HostConnectionLike { + readonly rpc: { + handle( + channel: string, + handler: (endpoint: string, payload: unknown, signal: AbortSignal) => Promise, + options: { readonly authority: 'trusted-host' | 'loopback' }, + ): () => Promise + } +} + +/** Dispatch failure produced outside the invoked business method. */ +export class TypertGatewayError extends Error { + /** Machine-readable failure category. */ + readonly code: TypertGatewayErrorCode + /** Canonical `/` endpoint. */ + readonly endpoint: string + /** Affected wire field when the failure is field-specific. */ + readonly field: string | undefined + + /** + * Construct a Gateway failure without embedding boundary values in its message. + * @param code - stable failure category. + * @param endpoint - canonical Remote endpoint. + * @param message - correction-oriented diagnostic without sensitive values. + * @param options - optional field and contained cause. + */ + constructor( + code: TypertGatewayErrorCode, + endpoint: string, + message: string, + options: GatewayErrorOptions = {}, + ) { + super(`typert gateway: ${endpoint}: ${message}`, options.cause === undefined ? undefined : { cause: options.cause }) + this.name = 'TypertGatewayError' + this.code = code + this.endpoint = endpoint + this.field = options.field + } +} + +/** + * Resolve strict generated definitions or conservative SRC markers against + * current Cordis Services and TypeRT providers. + * @typert service typertGateway + */ +export class TypertGatewayService extends Service implements TypertGateway { + static inject = ['typert'] + + /** + * Register the Gateway against the active TypeRT registry. + * @param ctx - owning Host Context with TypeRT registry access. + */ + constructor(ctx: Context) { + super(ctx, 'typertGateway') + ctx.inject(['connection'], (connectionCtx) => { + const connection = connectionCtx.get('connection') as unknown as HostConnectionLike + connection.rpc.handle( + '/api2', + (endpoint, payload, signal) => this.dispatchRpc(endpoint, payload, signal), + { authority: 'trusted-host' }, + ) + }) + } + + /** + * Invoke one live Remote method through strict generated reflection or SRC markers. + * @param request - decoded endpoint and exact named wire arguments. + * @returns the validated business result. + * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; business errors retain their identity. + */ + async invoke(request: InvokeRemoteRequest): Promise { + const endpoint = endpointOf(request.namespace, request.method) + const descriptor = this.resolveDescriptor(request.namespace, request.method, endpoint) + assertExactArguments(request.args, descriptor, endpoint) + const receiverContext = this.resolveReceiverContext(descriptor, request.args, endpoint) + const receiver = receiverContext.get(descriptor.service) as unknown + if (!isObject(receiver)) { + throw new TypertGatewayError( + 'service-unavailable', + endpoint, + `active Service ${JSON.stringify(descriptor.service)} is unavailable`, + ) + } + validateBinding(receiver, descriptor.service, descriptor.namespace, endpoint) + const args = descriptor.parameters.map(parameter => this.resolveParameter(parameter, request.args, endpoint)) + const implementation = descriptor.implementation ?? descriptor.method + const method = Reflect.get(receiver, implementation) as unknown + if (typeof method !== 'function') { + throw new TypertGatewayError( + 'method-unavailable', + endpoint, + `active Service ${JSON.stringify(descriptor.service)} has no callable method ${JSON.stringify(implementation)}`, + ) + } + + const result = await Reflect.apply(method, receiver, args) as unknown + return decode(descriptor.result, result, 'result-invalid', endpoint, 'result') + } + + private async dispatchRpc( + endpoint: string, + payload: unknown, + _signal: AbortSignal, + ): Promise { + return this.invokeRpc(endpoint, payload) + } + + private async invokeRpc(endpoint: string, payload: unknown): Promise { + try { + const segments = endpoint.split('/') + const namespace = segments[0] + const method = segments[1] + if (segments.length !== 2 || namespace === undefined || namespace === '' || method === undefined || method === '') { + throw new Error(`invalid Remote endpoint ${JSON.stringify(endpoint)}`) + } + if (!isObject(payload) + || !isPlainObject(payload) + || Reflect.ownKeys(payload).length !== 1 + || !Object.hasOwn(payload, 'args') + || !isObject(payload.args) + || !isPlainObject(payload.args)) { + throw new Error('Remote payload must contain exactly one plain-object args field') + } + const value = await this.invoke({ + namespace, + method, + args: payload.args, + }) + return { ok: true, value } + } catch (error) { + return rpcFailure(error) + } + } + + private resolveDescriptor(namespace: string, method: string, endpoint: string): InvocationDescriptor { + const strict = this.ctx.typert.local.get(endpoint) + if (strict !== undefined) return strict + if (this.ctx.typert.local.hasSeen(endpoint)) { + throw new TypertGatewayError( + 'definition-unavailable', + endpoint, + 'its strict definition was withdrawn and SRC fallback is forbidden', + ) + } + return this.resolveSrcDescriptor(namespace, method, endpoint) + } + + private resolveSrcDescriptor(namespace: string, method: string, endpoint: string): InvocationDescriptor { + const candidates: InvocationDescriptor[] = [] + for (const [serviceKey, definition] of Object.entries(this.ctx.reflect.props)) { + if (definition.type !== 'service') continue + const receiver = this.ctx.get(serviceKey) as unknown + if (!isObject(receiver)) continue + const original = originalOf(receiver) + const value = Reflect.get(original, 'typertGateway') as unknown + if (value === undefined) continue + const binding = readBinding(value, original, serviceKey, endpoint) + if (binding.namespace !== namespace) continue + const marker = remoteMethods(original).find(candidate => (candidate.exportName ?? candidate.method) === method) + if (marker === undefined) continue + candidates.push(this.srcDescriptor(binding, marker, method, endpoint)) + } + if (candidates.length === 0) { + throw new TypertGatewayError('invocation-unavailable', endpoint, 'no active Remote method exports this endpoint') + } + if (candidates.length > 1) { + throw new TypertGatewayError( + 'ambiguous-endpoint', + endpoint, + `multiple active Services export this endpoint: ${candidates.map(candidate => candidate.service).sort().join(', ')}`, + ) + } + return candidates[0] as InvocationDescriptor + } + + private srcDescriptor( + binding: TypeRTGatewayBinding, + marker: ReturnType[number], + method: string, + endpoint: string, + ): InvocationDescriptor { + const names = methodParameterNames(binding.service, marker.method, endpoint) + const parameters: InvocationParameterDescriptor[] = [] + const wires = new Set() + for (const name of names) { + const matches = this.ctx.typert.lookups.keys() + .map(key => ({ key, provider: this.ctx.typert.lookups.get(key) })) + .filter((entry): entry is { key: string; provider: TypeRTLookupProvider } => + entry.provider?.parameter === name) + if (matches.length > 1) { + throw new TypertGatewayError( + 'signature-invalid', + endpoint, + `parameter ${JSON.stringify(name)} matches multiple lookup providers`, + { field: name }, + ) + } + const match = matches[0] + const parameter: InvocationParameterDescriptor = match === undefined + ? { name, wire: name, source: 'json', codec: { mode: 'src-json' } } + : { + name, + wire: match.provider.wire, + source: 'lookup', + lookup: match.key, + codec: { mode: 'src-json' }, + } + if (wires.has(parameter.wire)) { + throw new TypertGatewayError( + 'signature-invalid', + endpoint, + `multiple parameters use wire field ${JSON.stringify(parameter.wire)}`, + { field: parameter.wire }, + ) + } + wires.add(parameter.wire) + parameters.push(parameter) + } + + let receiver: InvocationDescriptor['invocation'] = { kind: 'direct' } + if (marker.invocation.kind === 'context') { + const provider = this.ctx.typert.contexts.getHost(marker.invocation.context) + if (provider === undefined) { + throw new TypertGatewayError( + 'context-unavailable', + endpoint, + `Context provider ${JSON.stringify(marker.invocation.context)} is unavailable`, + ) + } + if (wires.has(provider.wire)) { + throw new TypertGatewayError( + 'signature-invalid', + endpoint, + `Context identity conflicts with wire field ${JSON.stringify(provider.wire)}`, + { field: provider.wire }, + ) + } + receiver = { + kind: 'context', + context: marker.invocation.context, + wire: provider.wire, + codec: { mode: 'src-json' }, + } + } + + return { + id: `src:${binding.serviceKey}#${endpoint}`, + service: binding.serviceKey, + namespace: binding.namespace, + method, + ...(marker.method === method ? {} : { implementation: marker.method }), + invocation: receiver, + parameters, + result: { mode: 'src-json' }, + } + } + + private resolveReceiverContext( + descriptor: InvocationDescriptor, + args: Readonly>, + endpoint: string, + ): Context { + if (descriptor.invocation.kind === 'direct') return this.ctx + const invocation = descriptor.invocation + const provider = this.ctx.typert.contexts.getHost(invocation.context) + if (provider === undefined) { + throw new TypertGatewayError( + 'context-unavailable', + endpoint, + `Context provider ${JSON.stringify(invocation.context)} is unavailable`, + ) + } + if (provider.wire !== invocation.wire + || (invocation.codec.mode === 'strict' && provider.wireTypeSymbol !== invocation.codec.typeSymbol)) { + throw new TypertGatewayError( + 'provider-mismatch', + endpoint, + `Context provider ${JSON.stringify(invocation.context)} does not match its strict definition`, + { field: invocation.wire }, + ) + } + const identity = decode(invocation.codec, args[invocation.wire], 'input-invalid', endpoint, invocation.wire) + let context: Context | undefined + try { + context = provider.resolve(identity) + } catch (cause) { + throw new TypertGatewayError( + 'context-failed', + endpoint, + `Context provider ${JSON.stringify(invocation.context)} failed`, + { cause, field: invocation.wire }, + ) + } + if (context === undefined) { + throw new TypertGatewayError( + 'context-not-found', + endpoint, + `Context provider ${JSON.stringify(invocation.context)} did not resolve the requested identity`, + { field: invocation.wire }, + ) + } + return context + } + + private resolveParameter( + parameter: InvocationParameterDescriptor, + args: Readonly>, + endpoint: string, + ): unknown { + const value = decode(parameter.codec, args[parameter.wire], 'input-invalid', endpoint, parameter.wire) + if (parameter.source === 'json') return value + const key = parameter.lookup + if (key === undefined) { + throw new TypertGatewayError( + 'lookup-unavailable', + endpoint, + `lookup parameter ${JSON.stringify(parameter.name)} has no provider key`, + { field: parameter.wire }, + ) + } + const provider = this.ctx.typert.lookups.get(key) + if (provider === undefined) { + throw new TypertGatewayError( + 'lookup-unavailable', + endpoint, + `lookup provider ${JSON.stringify(key)} is unavailable`, + { field: parameter.wire }, + ) + } + if (provider.wire !== parameter.wire + || (parameter.codec.mode === 'strict' && provider.wireTypeSymbol !== parameter.codec.typeSymbol)) { + throw new TypertGatewayError( + 'provider-mismatch', + endpoint, + `lookup provider ${JSON.stringify(key)} does not match its strict definition`, + { field: parameter.wire }, + ) + } + let resolved: unknown + try { + resolved = provider.resolve(value) + } catch (cause) { + throw new TypertGatewayError( + 'lookup-failed', + endpoint, + `lookup provider ${JSON.stringify(key)} failed`, + { cause, field: parameter.wire }, + ) + } + if (resolved === undefined) { + throw new TypertGatewayError( + 'lookup-not-found', + endpoint, + `lookup provider ${JSON.stringify(key)} did not resolve the requested identity`, + { field: parameter.wire }, + ) + } + return resolved + } +} + +function rpcFailure(error: unknown): ConnectionRpcResult { + return { + ok: false, + error: { + code: 'internal', + message: error instanceof Error ? error.message : String(error), + details: {}, + }, + } +} + +function endpointOf(namespace: string, method: string): string { + return `${namespace}/${method}` +} + +function validateBinding( + receiver: object, + serviceKey: string, + namespace: string, + endpoint: string, +): ResolvedBinding { + const original = originalOf(receiver) + const value = Reflect.get(original, 'typertGateway') as unknown + if (value === undefined) { + throw new TypertGatewayError( + 'binding-invalid', + endpoint, + `Service ${JSON.stringify(serviceKey)} has no visible typertGateway binding`, + ) + } + return { + binding: readBinding(value, original, serviceKey, endpoint, namespace), + original, + } +} + +function readBinding( + value: unknown, + original: object, + serviceKey: string, + endpoint: string, + namespace?: string, +): TypeRTGatewayBinding { + if (!isObject(value) + || Reflect.get(value, 'service') !== original + || Reflect.get(value, 'serviceKey') !== serviceKey + || typeof Reflect.get(value, 'namespace') !== 'string' + || (namespace !== undefined && Reflect.get(value, 'namespace') !== namespace)) { + throw new TypertGatewayError( + 'binding-invalid', + endpoint, + `Service ${JSON.stringify(serviceKey)} has an inconsistent typertGateway binding`, + ) + } + return value as unknown as TypeRTGatewayBinding +} + +function originalOf(receiver: object): object { + const original = Reflect.get(receiver, symbols.original) as unknown + return isObject(original) ? original : receiver +} + +function methodParameterNames(service: object, method: string, endpoint: string): readonly string[] { + let prototype: object | null = Object.getPrototypeOf(service) as object | null + let implementation: ((this: object, ...args: never[]) => unknown) | undefined + while (prototype !== null) { + const descriptor = Object.getOwnPropertyDescriptor(prototype, method) + if (descriptor !== undefined) { + if ('value' in descriptor && typeof descriptor.value === 'function') { + implementation = descriptor.value as (this: object, ...args: never[]) => unknown + } + break + } + prototype = Object.getPrototypeOf(prototype) as object | null + } + if (implementation === undefined) { + throw new TypertGatewayError( + 'method-unavailable', + endpoint, + `Remote marker has no prototype method ${JSON.stringify(method)}`, + ) + } + const source = Function.prototype.toString.call(implementation) + const open = source.indexOf('(') + const close = source.indexOf(')', open + 1) + if (open < 0 || close < 0) return invalidSignature(endpoint, method) + const body = source.slice(open + 1, close).trim() + if (body.length === 0) return [] + const parts = body.split(',').map(part => part.trim()) + if (parts.at(-1) === '') parts.pop() + const names = new Set() + for (const part of parts) { + if (!/^[$A-Z_a-z][$\w]*$/u.test(part) || names.has(part)) return invalidSignature(endpoint, method) + names.add(part) + } + return [...names] +} + +function invalidSignature(endpoint: string, method: string): never { + throw new TypertGatewayError( + 'signature-invalid', + endpoint, + `SRC method ${JSON.stringify(method)} must use unique identifier parameters without destructuring, defaults, or rest`, + ) +} + +function assertExactArguments( + args: Readonly>, + descriptor: InvocationDescriptor, + endpoint: string, +): void { + if (!isPlainObject(args)) { + throw new TypertGatewayError('arguments-invalid', endpoint, 'args must be a plain object') + } + const expected = new Set(descriptor.parameters.map(parameter => parameter.wire)) + if (descriptor.invocation.kind === 'context') expected.add(descriptor.invocation.wire) + const actual = Reflect.ownKeys(args) + const extra = actual.filter(key => typeof key !== 'string' || !expected.has(key)) + const missing = [...expected].filter(key => !Object.hasOwn(args, key)) + if (extra.length === 0 && missing.length === 0) return + const clauses: string[] = [] + if (missing.length > 0) clauses.push(`missing ${missing.map(key => JSON.stringify(key)).join(', ')}`) + if (extra.length > 0) clauses.push(`unexpected ${extra.map(key => JSON.stringify(String(key))).join(', ')}`) + throw new TypertGatewayError('arguments-invalid', endpoint, `args fields do not match the descriptor: ${clauses.join('; ')}`) +} + +function decode( + codec: TypeRTCodec, + value: unknown, + code: 'input-invalid' | 'result-invalid', + endpoint: string, + field: string, +): unknown { + try { + if (codec.mode === 'strict') return codec.schema.parse(value) + assertJsonValue(value, new Set()) + return value + } catch (cause) { + throw new TypertGatewayError( + code, + endpoint, + code === 'input-invalid' + ? `wire field ${JSON.stringify(field)} failed boundary validation` + : 'business result failed boundary validation', + { cause, field }, + ) + } +} + +function assertJsonValue(value: unknown, ancestors: Set): void { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return + if (typeof value === 'number') { + if (Number.isFinite(value)) return + throw new TypeError('non-finite number is not JSON-safe') + } + if (!isObject(value)) throw new TypeError(`${typeof value} is not JSON-safe`) + if (ancestors.has(value)) throw new TypeError('cyclic value is not JSON-safe') + ancestors.add(value) + try { + if (Array.isArray(value)) { + if (Object.getOwnPropertySymbols(value).length > 0 || Object.keys(value).length !== value.length) { + throw new TypeError('sparse or decorated array is not JSON-safe') + } + for (let index = 0; index < value.length; index += 1) { + if (!Object.hasOwn(value, index)) throw new TypeError('sparse array is not JSON-safe') + assertJsonValue(value[index], ancestors) + } + return + } + if (!isPlainObject(value)) throw new TypeError('non-plain object is not JSON-safe') + if (Object.getOwnPropertySymbols(value).length > 0) throw new TypeError('symbol property is not JSON-safe') + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== 'string') throw new TypeError('symbol property is not JSON-safe') + const descriptor = Object.getOwnPropertyDescriptor(value, key) + if (descriptor === undefined || !descriptor.enumerable || !('value' in descriptor)) { + throw new TypeError('non-data property is not JSON-safe') + } + assertJsonValue(descriptor.value, ancestors) + } + } finally { + ancestors.delete(value) + } +} + +function isPlainObject(value: object): value is Record { + if (Array.isArray(value)) return false + const prototype = Object.getPrototypeOf(value) as object | null + return prototype === null || prototype === Object.prototype +} + +function isObject(value: unknown): value is object { + return (typeof value === 'object' && value !== null) || typeof value === 'function' +} + +export default TypertGatewayService diff --git a/packages/host/api-gateway/src/invariant.ts b/packages/host/api-gateway/src/invariant.ts new file mode 100644 index 0000000000..65c94b4ac4 --- /dev/null +++ b/packages/host/api-gateway/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-host-api-gateway`. + * @module @deepseek-ai/dsh-host-api-gateway/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-host-api-gateway' + +/** Cordis companion plugin name. */ +export const name = 'host-api-gateway-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: Host calls re-read authoritative Cordis and TypeRT + * state, while Client methods and descriptors mutate in one owned effect. + */ +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/api-gateway/src/types.ts b/packages/host/api-gateway/src/types.ts new file mode 100644 index 0000000000..eea2bdc4f1 --- /dev/null +++ b/packages/host/api-gateway/src/types.ts @@ -0,0 +1,52 @@ +/** + * Carrier-independent TypeRT Gateway request, service, and error contracts. + * @module @deepseek-ai/dsh-host-api-gateway/types + */ + +/** One Remote method request after a carrier has decoded its envelope. */ +export interface InvokeRemoteRequest { + /** Remote namespace selected by the generated descriptor. */ + readonly namespace: string + /** Exported Service method name. */ + readonly method: string + /** Named wire values; fields must exactly match the descriptor. */ + readonly args: Readonly> +} + +/** Stable infrastructure and boundary failures emitted before or after business execution. */ +export type TypertGatewayErrorCode = + | 'ambiguous-endpoint' + | 'arguments-invalid' + | 'binding-invalid' + | 'context-failed' + | 'context-not-found' + | 'context-unavailable' + | 'definition-unavailable' + | 'input-invalid' + | 'invocation-unavailable' + | 'lookup-failed' + | 'lookup-not-found' + | 'lookup-unavailable' + | 'method-unavailable' + | 'provider-mismatch' + | 'result-invalid' + | 'service-unavailable' + | 'signature-invalid' + +/** Host dispatcher consumed by Connection adapters. */ +export interface TypertGateway { + /** + * Invoke one live Remote method without assuming a carrier or response envelope. + * @param request - decoded endpoint and named wire arguments. + * @returns the validated business result. + * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; business errors retain their identity. + */ + invoke(request: InvokeRemoteRequest): Promise +} + +declare module 'cordis' { + interface Context { + /** Host dispatcher for TypeRT Remote calls. */ + typertGateway: TypertGateway + } +} diff --git a/packages/host/api-gateway/tests/client.spec.ts b/packages/host/api-gateway/tests/client.spec.ts new file mode 100644 index 0000000000..be0b12ed51 --- /dev/null +++ b/packages/host/api-gateway/tests/client.spec.ts @@ -0,0 +1,222 @@ +import { Context } from 'cordis' +import { describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import type { + InvocationDescriptor, + TypeRTContext, + TypeRTRemoteContextApi, + TypeRTRemoteNamespace, +} from '@deepseek-ai/dsh-type-meta' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' +import { apply, inject } from '../src/client/index.ts' + +declare module '@deepseek-ai/dsh-type-meta' { + interface TypeRTContextMap { + fixture: TypeRTContext + } + + interface TypeRTRemoteMap { + 'goals/create': (agentId: string, request: { readonly objective: string }) => Promise<{ readonly ref: string }> + } + + interface TypeRTRemoteContextMap { + 'fixture:goals/create': (request: { readonly objective: string }) => Promise<{ readonly ref: string }> + 'fixture:goals/rename': (request: { readonly objective: string }) => Promise<{ readonly renamed: boolean }> + } + + interface TypeRTRemoteNamespaceMap { + goals: TypeRTRemoteNamespace<'goals'> + } + +} + +type FixtureContext = Context & TypeRTRemoteContextApi<'fixture'> + +const idSchema = z.string().min(1) +const requestSchema = z.object({ objective: z.string().min(1) }) +const createResultSchema = z.object({ ref: z.string().min(1) }) +const renameResultSchema = z.object({ renamed: z.boolean() }) + +function directDescriptor(): InvocationDescriptor { + return { + id: '@fixture/goals#goals/create', + service: 'goals', + namespace: 'goals', + method: 'create', + invocation: { kind: 'direct' }, + scope: { context: 'fixture', wire: 'agentId' }, + parameters: [{ + name: 'agent', + wire: 'agentId', + source: 'lookup', + lookup: 'fixture', + codec: { mode: 'strict', typeSymbol: '@fixture#AgentId', schema: idSchema }, + }, { + name: 'request', + wire: 'request', + source: 'json', + codec: { mode: 'strict', typeSymbol: '@fixture#CreateRequest', schema: requestSchema }, + }], + result: { mode: 'strict', typeSymbol: '@fixture#CreateResult', schema: createResultSchema }, + } +} + +function contextDescriptor(): InvocationDescriptor { + return { + id: '@fixture/goals#goals/rename', + service: 'goals', + namespace: 'goals', + method: 'rename', + invocation: { + kind: 'context', + context: 'fixture', + wire: 'agentId', + codec: { mode: 'strict', typeSymbol: '@fixture#AgentId', schema: idSchema }, + }, + parameters: [{ + name: 'request', + wire: 'request', + source: 'json', + codec: { mode: 'strict', typeSymbol: '@fixture#RenameRequest', schema: requestSchema }, + }], + result: { mode: 'strict', typeSymbol: '@fixture#RenameResult', schema: renameResultSchema }, + } +} + +async function bench(call: ConnectionHandle['rpc']['call']): Promise { + const ctx = new Context() + await ctx.plugin(TypertRegistry) + ctx.provide('connection', { rpc: { call } } as unknown as ConnectionHandle) + await ctx.plugin({ inject, apply }) + return ctx +} + +describe('Client TypeRT API', () => { + it('mounts concrete direct methods, validates both boundaries, and withdraws retained handles', async () => { + const call = vi.fn() + .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } }) + const ctx = await bench(call) + let retained: typeof ctx.api.goals.create | undefined + const assembly = ctx.plugin(Object.assign( + (scope: Context) => { + scope.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }) + retained = scope.api.goals.create + }, + { inject: ['api'] }, + )) + await assembly + + await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).resolves.toEqual({ ref: 'goal-1' }) + expect(call).toHaveBeenCalledWith( + '/api2', + 'goals/create', + { args: { agentId: 'agent-1', request: { objective: 'ship' } } }, + expect.any(AbortSignal), + ) + await expect(ctx.api.goals.create('', { objective: 'ship' })).rejects.toThrow('rejected "agentId"') + + call.mockResolvedValueOnce({ ok: true, value: { ref: 1 } }) + await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('rejected "result"') + + await assembly.dispose() + expect((ctx.api as unknown as Record).goals).toBeUndefined() + expect(ctx.get('goals')).toBeUndefined() + expect(ctx.typert.remotes.list()).toEqual([]) + await expect(retained?.('agent-1', { objective: 'ship' })).rejects.toThrow('no longer mounted') + }) + + it('projects one direct lookup descriptor onto an Agent-scoped alias', async () => { + const call = vi.fn() + .mockResolvedValue({ ok: true, value: { ref: 'goal-2' } }) + const ctx = await bench(call) + const agentCtx = ctx.extend({ fixtureId: 'agent-2' }) as FixtureContext + ctx.typert.contexts.registerClient('fixture', { + identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId, + }) + const assembly = ctx.plugin(Object.assign( + (scope: Context) => { + scope.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }) + }, + { inject: ['api'] }, + )) + await assembly + + await expect(agentCtx.goals.create({ objective: 'ship scoped' })).resolves.toEqual({ ref: 'goal-2' }) + expect(call).toHaveBeenCalledWith( + '/api2', + 'goals/create', + { args: { agentId: 'agent-2', request: { objective: 'ship scoped' } } }, + expect.any(AbortSignal), + ) + await expect((ctx as FixtureContext).goals.create({ objective: 'wrong scope' })) + .rejects.toThrow('requires a "fixture" Context') + + await assembly.dispose() + expect((ctx.api as unknown as Record).goals).toBeUndefined() + expect(ctx.get('goals')).toBeUndefined() + }) + + it('uses the caller Context identity for scoped namespace methods', async () => { + const call = vi.fn() + .mockResolvedValue({ ok: true, value: { renamed: true } }) + const ctx = await bench(call) + const agentCtx = ctx.extend({ fixtureId: 'agent-2' }) as FixtureContext + ctx.typert.contexts.registerClient('fixture', { + identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId, + }) + const assembly = ctx.plugin(Object.assign( + (scope: Context) => { + scope.api.mount({ package: '@fixture/goals', descriptors: [contextDescriptor()] }) + }, + { inject: ['api'] }, + )) + await assembly + + await expect(agentCtx.goals.rename({ objective: 'land' })).resolves.toEqual({ renamed: true }) + expect(call).toHaveBeenCalledWith( + '/api2', + 'goals/rename', + { args: { agentId: 'agent-2', request: { objective: 'land' } } }, + expect.any(AbortSignal), + ) + await expect((ctx as FixtureContext).goals.rename({ objective: 'land' })) + .rejects.toThrow('requires a "fixture" Context') + + await assembly.dispose() + expect(ctx.get('goals')).toBeUndefined() + }) + + it('rejects weak descriptors and namespace collisions before registration', async () => { + const ctx = await bench(vi.fn()) + const weak: InvocationDescriptor = { + ...directDescriptor(), + result: { mode: 'src-json' }, + } + + expect(() => ctx.api.mount({ package: '@fixture/weak', descriptors: [weak] })) + .toThrow('has no strict codec') + expect(() => ctx.api.mount({ + package: '@fixture/conflict', + descriptors: [{ ...directDescriptor(), namespace: 'mount' }], + })).toThrow('conflicts with the API service') + expect(ctx.typert.remotes.list()).toEqual([]) + }) + + it('throws RPC failures with the structured error as its cause', async () => { + const rpcError = { code: 'internal' as const, message: 'host failed', details: {} } + const ctx = await bench(vi.fn().mockResolvedValue({ ok: false, error: rpcError })) + ctx.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }) + + let failure: unknown + try { + await ctx.api.goals.create('agent-1', { objective: 'ship' }) + } catch (error) { + failure = error + } + expect(failure).toBeInstanceOf(Error) + if (!(failure instanceof Error)) throw new Error('expected Client API invocation to fail') + expect(failure.message).toContain('internal: host failed') + expect(failure.cause).toBe(rpcError) + }) +}) diff --git a/packages/host/api-gateway/tests/gateway.spec.ts b/packages/host/api-gateway/tests/gateway.spec.ts new file mode 100644 index 0000000000..8f7c144f5e --- /dev/null +++ b/packages/host/api-gateway/tests/gateway.spec.ts @@ -0,0 +1,795 @@ +import { createServer } from 'node:http' +import type { AddressInfo } from 'node:net' +import { describe, expect, it } from 'vitest' +import { Context, Service, symbols } from 'cordis' +import { z } from 'zod' +import { apply as applyConnection, inject as connectionInject } from '@deepseek-ai/dsh-client-connection' +import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver' +import { + bindTypeRTGateway, + Remote, + RemoteContext, + type InvocationDescriptor, + type TypeRTContext, + type TypeRTLookup, + type TypeRTLookupProvider, +} from '@deepseek-ai/dsh-type-meta' +import TypertRegistry, { type TypertContribution } from '@deepseek-ai/dsh-typert-registry' +import TypertGatewayService, { TypertGatewayError } from '@deepseek-ai/dsh-host-api-gateway' + +interface FixtureAgent { + readonly id: string +} + +interface MarkedContext extends Context { + readonly fixtureScope?: string +} + +declare module '@deepseek-ai/dsh-type-meta' { + interface TypeRTLookupMap { + gatewayFixture: TypeRTLookup + gatewayFixtureAlias: TypeRTLookup + } + + interface TypeRTContextMap { + gatewayFixture: TypeRTContext + } +} + +const emptyModel: TypertContribution['model'] = { + services: [], + events: [], + objects: [], +} + +class GoalService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'goals') + readonly calls: string[] = [] + nextResult: unknown = undefined + businessError: Error | undefined + + constructor(ctx: Context) { + super(ctx, 'goals') + } + + @Remote + create(agent: FixtureAgent, request: { readonly title: string }): unknown { + this.calls.push('create') + return { + agentId: agent.id, + title: request.title, + scope: (this.ctx as MarkedContext).fixtureScope ?? 'root', + } + } + + @RemoteContext('gatewayFixture') + rename(request: { readonly title: string }): unknown { + this.calls.push('rename') + return { title: request.title, scope: (this.ctx as MarkedContext).fixtureScope ?? 'root' } + } + + @Remote + passthrough(value: unknown): unknown { + this.calls.push('passthrough') + return this.nextResult === undefined ? value : this.nextResult + } + + @Remote + fail(request: unknown): never { + void request + this.calls.push('fail') + throw this.businessError ?? new Error('fixture business failure') + } + + strictOnly(request: { readonly title: string }): unknown { + this.calls.push('strictOnly') + return this.nextResult === undefined ? request : this.nextResult + } +} + +type FakeRpcResult = + | { readonly ok: true; readonly value: unknown } + | { readonly ok: false; readonly error: { readonly code: 'internal'; readonly message: string; readonly details: object } } + +type FakeRpcHandler = (endpoint: string, payload: unknown, signal: AbortSignal) => Promise + +class FakeConnectionService extends Service { + channel: string | undefined + authority: string | undefined + handler: FakeRpcHandler | undefined + + constructor(ctx: Context) { + super(ctx, 'connection') + } + + get rpc() { + const owner = this.ctx + return { + handle: (channel: string, handler: FakeRpcHandler, options: { readonly authority: string }) => + owner.effect(() => { + this.channel = channel + this.authority = options.authority + this.handler = handler + return () => { + this.channel = undefined + this.authority = undefined + this.handler = undefined + } + }), + } + } +} + +function fakeHttpServer(routes: WebRoute[]): Pick { + return { + register(route) { + if (routes.some(candidate => candidate.kind === route.kind && candidate.path === route.path)) { + throw new Error(`duplicate route ${route.path}`) + } + routes.push(route) + return () => { routes.splice(routes.indexOf(route), 1) } + }, + tapIndex: () => () => {}, + port: 0, + } +} + +async function serveRoute(route: WebRoute): Promise<{ readonly origin: string; close(): Promise }> { + const server = createServer((request, response) => { + void route.handler(request, response) + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() as AddressInfo + return { + origin: `http://127.0.0.1:${String(address.port)}`, + close: () => new Promise((resolve, reject) => { + server.close((error) => { + if (error === undefined || error === null) resolve() + else reject(error) + }) + }), + } +} + +class FirstSharedService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'firstShared', { namespace: 'shared' }) + + constructor(ctx: Context) { + super(ctx, 'firstShared') + } + + @Remote + run(value: string): string { + return value + } +} + +class SecondSharedService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'secondShared', { namespace: 'shared' }) + + constructor(ctx: Context) { + super(ctx, 'secondShared') + } + + @Remote + run(value: string): string { + return value + } +} + +class DefaultParameterService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'defaultParameter', { namespace: 'invalid-default' }) + + constructor(ctx: Context) { + super(ctx, 'defaultParameter') + } + + @Remote + run(value = 'fallback'): string { + return value + } +} + +class DestructuredParameterService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'destructuredParameter', { namespace: 'invalid-destructure' }) + + constructor(ctx: Context) { + super(ctx, 'destructuredParameter') + } + + @Remote + run({ value }: { readonly value: string }): string { + return value + } +} + +class RestParameterService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'restParameter', { namespace: 'invalid-rest' }) + + constructor(ctx: Context) { + super(ctx, 'restParameter') + } + + @Remote + run(...values: readonly unknown[]): string { + return values.map(String).join(',') + } +} + +class WrongBindingService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'notWrongBinding', { namespace: 'wrong-binding' }) + + constructor(ctx: Context) { + super(ctx, 'wrongBinding') + } + + @Remote + run(value: string): string { + return value + } +} + +describe('TypertGatewayService', () => { + it('invokes a strict direct method with schema decoding and a live lookup', async () => { + const { ctx, service } = await setup() + const agent = { id: 'agent-1' } + registerAgentLookup(ctx, agent) + registerStrict(ctx, [createDescriptor()]) + const caller = ctx.extend({ fixtureScope: 'direct-caller' }) + + await expect(caller.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: { agentId: 'agent-1', request: { title: ' ship ' } }, + })).resolves.toEqual({ agentId: 'agent-1', title: 'ship', scope: 'direct-caller' }) + expect(service.calls).toEqual(['create']) + }) + + it('resolves strict Remote Context identity without adding a business argument', async () => { + const { ctx, service } = await setup() + const scoped = ctx.extend({ fixtureScope: 'agent-scope' }) + ctx.typert.contexts.registerHost('gatewayFixture', contextProvider(scoped)) + registerStrict(ctx, [renameDescriptor()]) + + await expect(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'rename', + args: { agentId: 'agent-1', request: { title: 'land' } }, + })).resolves.toEqual({ title: 'land', scope: 'agent-scope' }) + expect(service.calls).toEqual(['rename']) + }) + + it('derives SRC direct lookup and JSON parameters from marker and parameter names', async () => { + const { ctx } = await setup() + const agent = { id: 'agent-1' } + registerAgentLookup(ctx, agent) + const caller = ctx.extend({ fixtureScope: 'direct-src' }) + + await expect(caller.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: { agentId: 'agent-1', request: { title: 'ship' } }, + })).resolves.toEqual({ agentId: 'agent-1', title: 'ship', scope: 'direct-src' }) + }) + + it('derives SRC Remote Context identity and preserves the scoped Proxy receiver', async () => { + const { ctx } = await setup() + const scoped = ctx.extend({ fixtureScope: 'agent-src' }) + ctx.typert.contexts.registerHost('gatewayFixture', contextProvider(scoped)) + + await expect(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'rename', + args: { agentId: 'agent-1', request: { title: 'land' } }, + })).resolves.toEqual({ title: 'land', scope: 'agent-src' }) + }) + + it('re-reads Service and providers on every strict invocation', async () => { + const { ctx, serviceFiber } = await setup() + const agent = { id: 'agent-1' } + const disposeLookup = registerAgentLookup(ctx, agent) + registerStrict(ctx, [createDescriptor()]) + + await disposeLookup() + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: { agentId: 'agent-1', request: { title: 'ship' } }, + }), 'lookup-unavailable') + + registerAgentLookup(ctx, agent) + await serviceFiber.dispose() + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: { agentId: 'agent-1', request: { title: 'ship' } }, + }), 'service-unavailable') + }) + + it('re-reads and contains Context providers', async () => { + const { ctx } = await setup() + const scoped = ctx.extend() + const dispose = ctx.typert.contexts.registerHost('gatewayFixture', contextProvider(scoped)) + registerStrict(ctx, [renameDescriptor()]) + + await dispose() + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'rename', + args: { agentId: 'agent-1', request: { title: 'land' } }, + }), 'context-unavailable') + + ctx.typert.contexts.registerHost('gatewayFixture', { + ...contextProvider(scoped), + resolve: () => { throw new Error('provider failed') }, + }) + const error = await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'rename', + args: { agentId: 'agent-1', request: { title: 'land' } }, + }), 'context-failed') + expect(error.cause).toEqual(new Error('provider failed')) + }) + + it('never downgrades an observed strict endpoint after definition disposal', async () => { + const { ctx } = await setup() + const dispose = registerStrict(ctx, [passthroughDescriptor()]) + await dispose() + + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'passthrough', + args: { value: 'would pass through SRC' }, + }), 'definition-unavailable') + }) + + it('seeds the no-downgrade guard from definitions present before Gateway startup', async () => { + const ctx = new Context() + await ctx.plugin(TypertRegistry) + const dispose = registerStrict(ctx, [passthroughDescriptor()]) + await ctx.plugin(TypertGatewayService) + await ctx.plugin(GoalService) + await dispose() + + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'passthrough', + args: { value: 'would pass through SRC' }, + }), 'definition-unavailable') + }) + + it('retains the no-downgrade guard across Gateway Service reloads', async () => { + const ctx = new Context() + await ctx.plugin(TypertRegistry) + const gatewayFiber = ctx.plugin(TypertGatewayService) + await gatewayFiber + await ctx.plugin(GoalService) + const dispose = registerStrict(ctx, [passthroughDescriptor()]) + await dispose() + + await gatewayFiber.dispose() + await ctx.plugin(TypertGatewayService) + + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'passthrough', + args: { value: 'would pass through SRC' }, + }), 'definition-unavailable') + }) + + it('rejects ambiguous SRC endpoints independently of reflection order', async () => { + const ctx = await setupGateway() + await ctx.plugin(FirstSharedService) + await ctx.plugin(SecondSharedService) + + const error = await expectCode(ctx.typertGateway.invoke({ + namespace: 'shared', + method: 'run', + args: { value: 'ship' }, + }), 'ambiguous-endpoint') + expect(error.message).toContain('firstShared, secondShared') + }) + + it('rejects SRC signatures that cannot map one wire field to each position', async () => { + const cases = [ + { plugin: DefaultParameterService, namespace: 'invalid-default', args: { value: 'x' } }, + { plugin: DestructuredParameterService, namespace: 'invalid-destructure', args: { value: { value: 'x' } } }, + { plugin: RestParameterService, namespace: 'invalid-rest', args: { values: ['x'] } }, + ] as const + for (const testCase of cases) { + const ctx = await setupGateway() + await ctx.plugin(testCase.plugin) + await expectCode(ctx.typertGateway.invoke({ + namespace: testCase.namespace, + method: 'run', + args: testCase.args, + }), 'signature-invalid') + } + }) + + it('rejects a SRC parameter matching more than one lookup provider', async () => { + const { ctx } = await setup() + const provider = agentLookup({ id: 'agent-1' }) + ctx.typert.lookups.register('gatewayFixture', provider) + ctx.typert.lookups.register('gatewayFixtureAlias', provider) + + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: { agentId: 'agent-1', request: { title: 'ship' } }, + }), 'signature-invalid') + }) + + it('requires exact wire fields before invoking business code', async () => { + const { ctx, service } = await setup() + registerAgentLookup(ctx, { id: 'agent-1' }) + + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: { request: { title: 'ship' } }, + }), 'arguments-invalid') + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: { agentId: 'agent-1', request: { title: 'ship' }, optional: true }, + }), 'arguments-invalid') + expect(service.calls).toEqual([]) + }) + + it('distinguishes strict input and result validation failures', async () => { + const { ctx, service } = await setup() + registerStrict(ctx, [strictOnlyDescriptor()]) + + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'strictOnly', + args: { request: { title: 1 } }, + }), 'input-invalid') + + service.nextResult = { title: 1 } + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'strictOnly', + args: { request: { title: 'ship' } }, + }), 'result-invalid') + }) + + it.each([ + undefined, + Number.NaN, + Number.POSITIVE_INFINITY, + 1n, + Symbol('value'), + () => 'value', + new Date(0), + new Map(), + [, 'sparse'], + ])('rejects non-JSON SRC input %#', async (value) => { + const { ctx } = await setup() + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'passthrough', + args: { value }, + }), 'input-invalid') + }) + + it('rejects cyclic SRC input and non-JSON SRC results', async () => { + const { ctx, service } = await setup() + const cyclic: { self?: unknown } = {} + cyclic.self = cyclic + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'passthrough', + args: { value: cyclic }, + }), 'input-invalid') + + service.nextResult = new Date(0) + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'passthrough', + args: { value: null }, + }), 'result-invalid') + }) + + it('validates strict provider identity against generated wire metadata', async () => { + const { ctx } = await setup() + ctx.typert.lookups.register('gatewayFixture', { + ...agentLookup({ id: 'agent-1' }), + wire: 'differentAgentId', + }) + registerStrict(ctx, [createDescriptor()]) + + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: { agentId: 'agent-1', request: { title: 'ship' } }, + }), 'provider-mismatch') + }) + + it('validates binding identity and active method availability', async () => { + const ctx = await setupGateway() + await ctx.plugin(WrongBindingService) + await expectCode(ctx.typertGateway.invoke({ + namespace: 'wrong-binding', + method: 'run', + args: { value: 'ship' }, + }), 'binding-invalid') + + await ctx.plugin(GoalService) + registerStrict(ctx, [{ ...passthroughDescriptor(), method: 'missing' }]) + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'missing', + args: { value: 'ship' }, + }), 'method-unavailable') + }) + + it('preserves business exception identity after invocation begins', async () => { + const { ctx, service } = await setup() + const failure = new Error('business identity') + service.businessError = failure + + await expect(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'fail', + args: { request: { reason: 'fixture' } }, + })).rejects.toBe(failure) + }) + + it('reports an absent endpoint without retaining receiver state', async () => { + const { ctx } = await setup() + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'absent', + args: {}, + }), 'invocation-unavailable') + }) + + it('mounts /api2 through an optional Connection and returns existing RPC results', async () => { + const ctx = new Context().extend({ fixtureScope: 'rpc-caller' }) + await ctx.plugin(TypertRegistry) + await ctx.plugin(FakeConnectionService) + const gatewayFiber = ctx.plugin(TypertGatewayService) + await gatewayFiber + await ctx.plugin(GoalService) + const connection = rawConnection(ctx) + expect(connection).toMatchObject({ channel: '/api2', authority: 'trusted-host' }) + + registerAgentLookup(ctx, { id: 'agent-1' }) + registerStrict(ctx, [createDescriptor()]) + const signal = new AbortController().signal + const handler = connection.handler + if (handler === undefined) throw new Error('fixture Connection did not retain the /api2 handler') + await expect(handler('goals/create', { + args: { agentId: 'agent-1', request: { title: 'ship' } }, + }, signal)).resolves.toEqual({ + ok: true, + value: { agentId: 'agent-1', title: 'ship', scope: 'rpc-caller' }, + }) + const invalid = await handler('goals/create', { invalid: true }, signal) + expect(invalid).toMatchObject({ + ok: false, + error: { code: 'internal' }, + }) + if (invalid.ok) throw new Error('invalid Remote payload unexpectedly succeeded') + expect(invalid.error.message).toMatch(/exactly one plain-object args field/) + + await gatewayFiber.dispose() + expect(connection.handler).toBeUndefined() + }) + + it('dispatches a generated invocation through the real /api2 HTTP carrier', async () => { + const ctx = new Context().extend({ fixtureScope: 'http-caller' }) + const routes: WebRoute[] = [] + ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService) + const connectionFiber = ctx.plugin({ inject: [...connectionInject], apply: applyConnection }) + await connectionFiber + await ctx.plugin(TypertRegistry) + const gatewayFiber = ctx.plugin(TypertGatewayService) + await gatewayFiber + const goalFiber = ctx.plugin(GoalService) + await goalFiber + const removeLookup = registerAgentLookup(ctx, { id: 'agent-1' }) + const removeStrict = registerStrict(ctx, [createDescriptor()]) + expect(routes).toHaveLength(1) + const server = await serveRoute(routes[0]!) + + try { + const response = await fetch(`${server.origin}/api2/goals/create`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', + rpcId: 'rpc-http', + method: 'goals/create', + payload: { args: { agentId: 'agent-1', request: { title: ' ship ' } } }, + }), + }) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + type: 'server-response', + rpcId: 'rpc-http', + result: { + ok: true, + value: { agentId: 'agent-1', title: 'ship', scope: 'http-caller' }, + }, + }) + } finally { + await server.close() + await removeStrict() + await removeLookup() + await goalFiber.dispose() + await gatewayFiber.dispose() + await connectionFiber.dispose() + } + expect(routes).toHaveLength(0) + }) +}) + +async function setup(): Promise<{ + readonly ctx: Context + readonly service: GoalService + readonly serviceFiber: ReturnType +}> { + const ctx = await setupGateway() + const serviceFiber = ctx.plugin(GoalService) + await serviceFiber + return { ctx, service: rawGoalService(ctx), serviceFiber } +} + +async function setupGateway(): Promise { + const ctx = new Context() + await ctx.plugin(TypertRegistry) + await ctx.plugin(TypertGatewayService) + return ctx +} + +function rawGoalService(ctx: Context): GoalService { + const receiver = ctx.get('goals') as unknown as GoalService & { [symbols.original]?: GoalService } + return receiver[symbols.original] ?? receiver +} + +function rawConnection(ctx: Context): FakeConnectionService { + const receiver = ctx.get('connection') as unknown as FakeConnectionService & { + [symbols.original]?: FakeConnectionService + } + return receiver[symbols.original] ?? receiver +} + +function registerStrict(ctx: Context, descriptors: readonly InvocationDescriptor[]): () => Promise { + return ctx.typert.register({ + package: '@fixture/gateway', + face: 'host', + schemas: [], + model: emptyModel, + invocations: descriptors, + }) +} + +function registerAgentLookup(ctx: Context, agent: FixtureAgent): () => Promise { + return ctx.typert.lookups.register('gatewayFixture', agentLookup(agent)) +} + +function agentLookup(agent: FixtureAgent): TypeRTLookupProvider { + return { + parameter: 'agent', + wire: 'agentId', + hostTypeSymbol: '@fixture/domain#Agent', + wireTypeSymbol: '@fixture/domain#AgentId', + resolve: id => id === agent.id ? agent : undefined, + } +} + +function contextProvider(context: Context) { + return { + wire: 'agentId', + wireTypeSymbol: '@fixture/domain#AgentId', + resolve: (id: string) => id === 'agent-1' ? context : undefined, + } +} + +function strictCodec(typeSymbol: string, schema: z.ZodType): InvocationDescriptor['result'] { + return { mode: 'strict', typeSymbol, schema } +} + +function createDescriptor(): InvocationDescriptor { + return { + id: '@fixture/gateway#goals/create', + service: 'goals', + namespace: 'goals', + method: 'create', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'agent', + wire: 'agentId', + source: 'lookup', + lookup: 'gatewayFixture', + codec: strictCodec('@fixture/domain#AgentId', z.string()), + }, + { + name: 'request', + wire: 'request', + source: 'json', + codec: strictCodec('@fixture/gateway#CreateRequest', z.object({ + title: z.string().transform(value => value.trim()), + })), + }, + ], + result: strictCodec('@fixture/gateway#CreateResult', z.object({ + agentId: z.string(), + title: z.string(), + scope: z.string(), + })), + } +} + +function renameDescriptor(): InvocationDescriptor { + return { + id: '@fixture/gateway#goals/rename', + service: 'goals', + namespace: 'goals', + method: 'rename', + invocation: { + kind: 'context', + context: 'gatewayFixture', + wire: 'agentId', + codec: strictCodec('@fixture/domain#AgentId', z.string()), + }, + parameters: [{ + name: 'request', + wire: 'request', + source: 'json', + codec: strictCodec('@fixture/gateway#RenameRequest', z.object({ title: z.string() })), + }], + result: strictCodec('@fixture/gateway#RenameResult', z.object({ + title: z.string(), + scope: z.string(), + })), + } +} + +function passthroughDescriptor(): InvocationDescriptor { + return { + id: '@fixture/gateway#goals/passthrough', + service: 'goals', + namespace: 'goals', + method: 'passthrough', + invocation: { kind: 'direct' }, + parameters: [{ + name: 'value', + wire: 'value', + source: 'json', + codec: { mode: 'src-json' }, + }], + result: { mode: 'src-json' }, + } +} + +function strictOnlyDescriptor(): InvocationDescriptor { + const value = strictCodec('@fixture/gateway#StrictValue', z.object({ title: z.string() })) + return { + id: '@fixture/gateway#goals/strictOnly', + service: 'goals', + namespace: 'goals', + method: 'strictOnly', + invocation: { kind: 'direct' }, + parameters: [{ name: 'request', wire: 'request', source: 'json', codec: value }], + result: value, + } +} + +async function expectCode( + promise: Promise, + code: TypertGatewayError['code'], +): Promise { + try { + await promise + } catch (error) { + expect(error).toBeInstanceOf(TypertGatewayError) + expect(error).toMatchObject({ code }) + return error as TypertGatewayError + } + throw new Error(`expected TypertGatewayError ${code}`) +} diff --git a/packages/host/api-gateway/tsconfig.json b/packages/host/api-gateway/tsconfig.json new file mode 100644 index 0000000000..fea39663f7 --- /dev/null +++ b/packages/host/api-gateway/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../support/invariants" + }, + { + "path": "../../client/connection" + }, + { + "path": "../../typert/type-meta" + } + ] +} diff --git a/packages/host/api-gateway/tsdown.config.ts b/packages/host/api-gateway/tsdown.config.ts new file mode 100644 index 0000000000..1f95a1f2c5 --- /dev/null +++ b/packages/host/api-gateway/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../../client/tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-host-api-gateway', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index c9dea52f98..cb83c5328d 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -72,6 +72,11 @@ export type { // ---- Errors and ids ---- export { RpcId, transportError } from './rpc.ts' export type { RpcError, RpcErrorCode, RpcErrorDetailsMap, RpcResult } from './rpc.ts' +export { + clientRequestSchema, + serverRequestSchema, + serverResponseSchema, +} from './rpc.schema.ts' // ---- Fixed session-search product bounds ---- export { diff --git a/packages/typert/generator/package.json b/packages/typert/generator/package.json index 3e9d8f7f61..5ffb933214 100644 --- a/packages/typert/generator/package.json +++ b/packages/typert/generator/package.json @@ -30,6 +30,7 @@ ], "license": "BSD-3-Clause", "dependencies": { + "@jridgewell/gen-mapping": "^0.3.13", "typescript": "^6.0.3" }, "peerDependencies": { diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index 005b8e2157..5757d7cef5 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -15,6 +15,8 @@ import type { EnumMemberModel, ExportModel, FaceModel, + InvocationModel, + InvocationParameterModel, JsDocTagModel, KeywordTypeName, MemberBase, @@ -23,6 +25,8 @@ import type { ObjectModel, PackageModel, ParameterModel, + RemoteBoundaryModel, + RemoteTypeImportModel, SchemaModel, ServiceModel, SignatureModel, @@ -122,6 +126,25 @@ interface ModuleIdentity { readonly subpath: string } +interface StaticLookupDeclaration { + readonly key: string + readonly hostSymbol: SymbolId + readonly wireType: ts.TypeNode + readonly site: ts.Node +} + +interface StaticContextDeclaration { + readonly key: string + readonly wireType: ts.TypeNode + readonly site: ts.Node +} + +interface GatewayBinding { + readonly service: string + readonly namespace: string + readonly site: ts.PropertyDeclaration +} + type ReferenceSite = ts.TypeReferenceNode | ts.ExpressionWithTypeArguments | ts.ImportTypeNode const EMPTY_DOCUMENTATION: DocumentationModel = { tags: [] } @@ -453,15 +476,11 @@ export class WorkspaceAnalyzer { config: this.caches.config(configPath), manifest, } - const packagePath = slash(relative(this.options.root, packageRoot)) - const clientPackage = packagePath === 'packages/client' || packagePath.startsWith('packages/client/') - if (clientPackage && isDualFacePackage(manifest)) { + if (isDualFacePackage(manifest)) { registrations.push({ ...registration, face: 'host', exportSubpaths: hostExportSubpaths(manifest) }) registrations.push({ ...registration, face: 'client', exportSubpaths: clientExportSubpaths(manifest) }) - } else if (clientPackage) { - registrations.push({ ...registration, face: 'client' }) } else { - registrations.push({ ...registration, face: 'host' }) + registrations.push(registration) } } } @@ -480,6 +499,7 @@ export class WorkspaceAnalyzer { && subpath !== './package.json' && subpath !== './typert' && subpath !== './client/typert' + && subpath !== './remote' && !target.endsWith('.json')) .map(([, target]) => sourcePathForExport(registration.root, target)) .filter(existsSync) @@ -578,6 +598,8 @@ class FaceAnalyzer { private readonly nodes = new Map() private readonly exportsByPackage = new Map() private readonly nodeOrdinals = new Map() + private staticLookups: readonly StaticLookupDeclaration[] | undefined + private staticContexts: ReadonlyMap | undefined constructor(options: FaceAnalyzerOptions) { this.root = options.root @@ -601,6 +623,7 @@ class FaceAnalyzer { const packages = this.registrations .map(registration => this.analyzePackage(registration)) .filter(hasPackageSurface) + this.validateInvocationIdentity(packages) return { face: this.face, packages, @@ -634,6 +657,7 @@ class FaceAnalyzer { } } } + const explicitServices = this.collectExplicitServices(records) const objects: ObjectModel[] = [] const schemas: SchemaModel[] = [] @@ -672,10 +696,14 @@ class FaceAnalyzer { root: slash(relative(this.root, registration.root)), exports: records.map(record => record.model) .sort((left, right) => left.subpath.localeCompare(right.subpath) || left.name.localeCompare(right.name)), - services: uniqueBy(services, service => service.key).sort((left, right) => left.key.localeCompare(right.key)), + services: uniqueBy([...explicitServices, ...services], service => service.key) + .sort((left, right) => left.key.localeCompare(right.key)), events: uniqueBy(events, event => event.name).sort((left, right) => left.name.localeCompare(right.name)), objects: objects.sort((left, right) => left.export.name.localeCompare(right.export.name)), schemas: schemas.sort((left, right) => left.export.name.localeCompare(right.export.name)), + invocations: this.face === 'host' + ? this.collectInvocations(registration, reachable).sort((left, right) => left.id.localeCompare(right.id)) + : [], } } @@ -686,7 +714,7 @@ class FaceAnalyzer { const records: ExportRecord[] = [] for (const [subpath, target] of targets) { if (target.includes('*') || subpath === './package.json' - || subpath === './typert' || subpath === './client/typert' + || subpath === './typert' || subpath === './client/typert' || subpath === './remote' // Data exports (bundle patch lists, JSON manifests) carry no TypeScript API. || target.endsWith('.json') || target.endsWith('.yml') || target.endsWith('.yaml')) continue const sourcePath = sourcePathForExport(registration.root, target) @@ -849,6 +877,740 @@ class FaceAnalyzer { return result } + private collectExplicitServices(records: readonly ExportRecord[]): ServiceModel[] { + const result: ServiceModel[] = [] + const seen = new Set() + for (const record of records) { + const tag = typertServiceTag(record.declaration) + if (tag === undefined) continue + const words = (ts.getTextOfJSDocComment(tag.comment) ?? '').trim().split(/\s+/) + if (words.length !== 2 || !isRemoteSegment(words[1] ?? '')) { + this.fail(tag, '@typert service requires exactly one nonempty Cordis service key without "/"') + } + if (!ts.isClassDeclaration(record.declaration)) { + this.fail(record.declaration, '@typert service requires an exported class') + } + const symbol = this.resolveSymbol(record.symbol) + const symbolId = this.symbolId(symbol) + if (seen.has(symbolId)) continue + seen.add(symbolId) + const model = this.ensureDeclaration(symbol, record.declaration) + result.push({ + ...documentationOf(record.declaration), + key: words[1] as string, + symbol: symbolId, + export: record.model, + members: model.members.filter(exposableMember).map(member => member.id), + location: this.location(record.declaration), + }) + } + return result + } + + private collectInvocations( + registration: PackageRegistration, + reachable: readonly ts.SourceFile[], + ): InvocationModel[] { + const result: InvocationModel[] = [] + for (const sourceFile of reachable) { + for (const statement of sourceFile.statements) { + if (!ts.isClassDeclaration(statement)) continue + const marked = statement.members.flatMap((member) => { + const invocation = this.remoteMarker(member) + if (invocation === undefined) return [] + if (!ts.isMethodDeclaration(member)) { + this.fail(member, 'Remote decorators require a public instance method') + } + return [{ method: member, invocation }] + }) + const first = marked[0] + if (first === undefined) continue + const binding = this.gatewayBinding(statement) + if (binding === undefined) { + this.fail(first.method, 'Remote methods require readonly typertGateway = bindTypeRTGateway(this, serviceKey)') + } + for (const { method, invocation } of marked) { + result.push(this.invocationModel(registration, binding, method, invocation)) + } + } + } + return result + } + + private invocationModel( + registration: PackageRegistration, + binding: GatewayBinding, + method: ts.MethodDeclaration, + invocation: + | { readonly kind: 'direct'; readonly exportName?: string } + | { readonly kind: 'context'; readonly context: string; readonly exportName?: string }, + ): InvocationModel { + if (visibilityOf(method) !== 'public' || hasModifier(method, ts.SyntaxKind.StaticKeyword)) { + this.fail(method, 'Remote decorators require a public instance method') + } + if (hasModifier(method, ts.SyntaxKind.AbstractKeyword) || method.body === undefined) { + this.fail(method, 'Remote methods must have a concrete implementation') + } + if (!ts.isIdentifier(method.name)) { + this.fail(method, 'Remote method names must be identifiers') + } + if ((method.typeParameters?.length ?? 0) > 0) { + this.fail(method, 'generic Remote methods are not supported') + } + const methodName = method.name.text + const exportedMethod = invocation.exportName ?? methodName + + const lookups = this.lookupDeclarations() + const lookupByHost = new Map(lookups.map(lookup => [lookup.hostSymbol, lookup])) + const parameters: InvocationParameterModel[] = [] + const wires = new Set() + for (const parameter of method.parameters) { + if (!ts.isIdentifier(parameter.name)) { + this.fail(parameter, 'Remote parameters must use identifier bindings') + } + if (parameter.dotDotDotToken !== undefined) this.fail(parameter, 'Remote parameters cannot be rest parameters') + if (parameter.initializer !== undefined) this.fail(parameter, 'Remote parameters cannot have default values') + if (parameter.questionToken !== undefined) this.fail(parameter, 'Remote parameters cannot be optional') + if (parameter.name.text === 'this') this.fail(parameter, 'Remote methods cannot declare an explicit this parameter') + const authoredType = this.requiredType(parameter, parameter.type, 'parameter') + const hostSymbol = this.symbolAtType(authoredType) + const lookup = hostSymbol === undefined ? undefined : lookupByHost.get(this.symbolId(hostSymbol)) + let modeled: InvocationParameterModel + if (lookup !== undefined) { + if (parameter.name.text !== lookup.key) { + this.fail(parameter, `lookup parameter for ${lookup.key} must also be named ${lookup.key}`) + } + const boundary = this.remoteBoundary( + lookup.wireType, + `${registration.name}#${binding.namespace}/${exportedMethod}:${lookup.key}Id`, + true, + ) + modeled = { + name: parameter.name.text, + wire: `${lookup.key}Id`, + source: 'lookup', + lookup: lookup.key, + boundary, + } + } else { + if (hostSymbol !== undefined && this.isWorkspaceClass(hostSymbol)) { + this.fail(parameter, `non-JSON class parameter ${hostSymbol.name} requires a TypeRTLookupMap entry`) + } + modeled = { + name: parameter.name.text, + wire: parameter.name.text, + source: 'json', + boundary: this.remoteBoundary( + authoredType, + `${registration.name}#${binding.namespace}/${exportedMethod}:${parameter.name.text}`, + false, + ), + } + } + if (wires.has(modeled.wire)) this.fail(parameter, `duplicate Remote wire field ${modeled.wire}`) + wires.add(modeled.wire) + parameters.push(modeled) + } + + let receiver: InvocationModel['invocation'] = { kind: 'direct' } + if (invocation.kind === 'context') { + const context = this.contextDeclarations().get(invocation.context) + if (context === undefined) { + this.fail(method, `Remote Context ${invocation.context} has no TypeRTContextMap entry`) + } + const wire = `${invocation.context}Id` + if (wires.has(wire)) this.fail(method, `Remote Context wire field ${wire} conflicts with a method parameter`) + receiver = { + kind: 'context', + context: invocation.context, + wire, + boundary: this.remoteBoundary( + context.wireType, + `${registration.name}#${binding.namespace}/${exportedMethod}:${wire}`, + true, + ), + } + } + + let scope: InvocationModel['scope'] + if (invocation.kind === 'direct') { + const lookupParameters = parameters.filter(parameter => parameter.source === 'lookup') + const parameter = lookupParameters.length === 1 ? lookupParameters[0] : undefined + const context = parameter?.lookup === undefined + ? undefined + : this.contextDeclarations().get(parameter.lookup) + if (parameter !== undefined && context !== undefined) { + const contextBoundary = this.remoteBoundary( + context.wireType, + `${registration.name}#${binding.namespace}/${exportedMethod}:scope:${context.key}`, + true, + ) + if (contextBoundary.typeSymbol !== parameter.boundary.typeSymbol) { + this.fail( + method, + `Remote scope ${context.key} wire type ${contextBoundary.typeSymbol} does not match lookup wire type ${parameter.boundary.typeSymbol}`, + ) + } + scope = { context: context.key, wire: parameter.wire } + } + } + + const resultType = this.remoteResultType(method) + return { + id: `${registration.name}#${binding.namespace}/${exportedMethod}`, + service: binding.service, + namespace: binding.namespace, + method: exportedMethod, + ...(exportedMethod === methodName ? {} : { implementation: methodName }), + invocation: receiver, + ...(scope === undefined ? {} : { scope }), + parameters, + result: this.remoteBoundary( + resultType, + `${registration.name}#${binding.namespace}/${exportedMethod}:result`, + false, + ), + location: this.location(method.name), + } + } + + private gatewayBinding(declaration: ts.ClassDeclaration): GatewayBinding | undefined { + const candidates = declaration.members.filter((member): member is ts.PropertyDeclaration => + ts.isPropertyDeclaration(member) && memberName(member.name) === 'typertGateway') + const [property, duplicate] = candidates + if (property === undefined) return undefined + if (duplicate !== undefined) this.fail(duplicate, 'Service has more than one typertGateway field') + if (visibilityOf(property) !== 'public' + || hasModifier(property, ts.SyntaxKind.StaticKeyword) + || !hasModifier(property, ts.SyntaxKind.ReadonlyKeyword)) { + this.fail(property, 'typertGateway must be a public readonly instance field') + } + if (property.initializer === undefined + || !ts.isCallExpression(property.initializer) + || !this.isTypeMetaSymbol(property.initializer.expression, 'bindTypeRTGateway')) { + this.fail(property, 'typertGateway must call bindTypeRTGateway()') + } + const call = property.initializer + if (call.arguments.length < 2 || call.arguments.length > 3) { + this.fail(call, 'bindTypeRTGateway() requires this, service key, and an optional options object') + } + if (call.arguments[0]?.kind !== ts.SyntaxKind.ThisKeyword) { + this.fail(call.arguments[0] ?? call, 'bindTypeRTGateway() first argument must be this') + } + const serviceArgument = call.arguments[1] + if (serviceArgument === undefined) this.fail(call, 'bindTypeRTGateway() service key must be a string literal') + const service = stringLiteralValue(serviceArgument) + if (service === undefined) this.fail(serviceArgument, 'bindTypeRTGateway() service key must be a string literal') + let namespace = service + const options = call.arguments[2] + if (options !== undefined) { + if (!ts.isObjectLiteralExpression(options)) { + this.fail(options, 'bindTypeRTGateway() options must be an object literal') + } + for (const propertyOption of options.properties) { + if (!ts.isPropertyAssignment(propertyOption) + || memberName(propertyOption.name) !== 'namespace') { + this.fail(propertyOption, 'bindTypeRTGateway() only supports a namespace option') + } + const value = stringLiteralValue(propertyOption.initializer) + if (value === undefined) this.fail(propertyOption.initializer, 'Gateway namespace must be a string literal') + namespace = value + } + } + if (!isRemoteSegment(service)) this.fail(serviceArgument, 'Gateway service key must be nonempty and must not contain "/"') + if (!isRemoteSegment(namespace)) this.fail(options ?? call, 'Gateway namespace must be nonempty and must not contain "/"') + return { service, namespace, site: property } + } + + private remoteMarker( + member: ts.ClassElement, + ): + | { readonly kind: 'direct'; readonly exportName?: string } + | { readonly kind: 'context'; readonly context: string; readonly exportName?: string } + | undefined { + let found: + | { readonly kind: 'direct'; readonly exportName?: string } + | { readonly kind: 'context'; readonly context: string; readonly exportName?: string } + | undefined + for (const decorator of ts.canHaveDecorators(member) ? ts.getDecorators(member) ?? [] : []) { + const expression = decorator.expression + let marker: typeof found + if (this.isTypeMetaSymbol(expression, 'Remote')) { + marker = { kind: 'direct' } + } else if (ts.isCallExpression(expression) + && this.isTypeMetaSymbol(expression.expression, 'Remote')) { + if (expression.arguments.length !== 1) this.fail(expression, 'Remote() requires one exported method name') + const exportName = stringLiteralValue(expression.arguments[0]) + if (exportName === undefined || !isRemoteSegment(exportName)) { + this.fail(expression.arguments[0] ?? expression, 'Remote() name must be a nonempty string literal without "/"') + } + marker = { kind: 'direct', exportName } + } else if (ts.isCallExpression(expression) + && this.isTypeMetaSymbol(expression.expression, 'RemoteContext')) { + if (expression.arguments.length < 1 || expression.arguments.length > 2) { + this.fail(expression, 'RemoteContext() requires a Context key and optional exported method name') + } + const context = stringLiteralValue(expression.arguments[0]) + if (context === undefined || !isRemoteSegment(context)) { + this.fail(expression.arguments[0] ?? expression, 'RemoteContext() key must be a nonempty string literal without "/"') + } + const exportArgument = expression.arguments[1] + const exportName = exportArgument === undefined ? undefined : stringLiteralValue(exportArgument) + if (exportArgument !== undefined && (exportName === undefined || !isRemoteSegment(exportName))) { + this.fail(exportArgument, 'RemoteContext() name must be a nonempty string literal without "/"') + } + marker = { kind: 'context', context, ...exportName === undefined ? {} : { exportName } } + } else { + continue + } + if (found !== undefined) this.fail(decorator, 'a method can have only one Remote invocation decorator') + found = marker + } + return found + } + + private remoteResultType(method: ts.MethodDeclaration): ts.TypeNode { + const authored = this.requiredType(method, method.type, 'return') + if (!ts.isTypeReferenceNode(authored)) return authored + const symbol = this.checker.getSymbolAtLocation(authored.typeName) + const resolved = symbol === undefined ? undefined : this.resolveSymbol(symbol) + const resultType = authored.typeArguments?.[0] + if (resolved?.name !== 'Promise' || resultType === undefined || authored.typeArguments?.length !== 1) return authored + const declaration = preferredDeclaration(resolved) + if (declaration === undefined || !isStandardLibraryFile(declaration.getSourceFile().fileName)) return authored + return resultType + } + + private lookupDeclarations(): readonly StaticLookupDeclaration[] { + if (this.staticLookups !== undefined) return this.staticLookups + const byKey = new Map() + const byHost = new Map() + for (const declaration of this.typeMetaMapMembers('TypeRTLookupMap')) { + if (!ts.isPropertySignature(declaration) || declaration.type === undefined) { + this.fail(declaration, 'TypeRTLookupMap entries must be required properties') + } + const key = memberName(declaration.name) + if (!isRemoteSegment(key)) this.fail(declaration.name, 'TypeRTLookupMap key must be nonempty and must not contain "/"') + if (!ts.isTypeReferenceNode(declaration.type) + || !this.isTypeMetaSymbol(declaration.type.typeName, 'TypeRTLookup') + || declaration.type.typeArguments?.length !== 2) { + this.fail(declaration.type, 'TypeRTLookupMap values must be TypeRTLookup') + } + const hostType = declaration.type.typeArguments[0] + const wireType = declaration.type.typeArguments[1] + if (hostType === undefined || wireType === undefined) { + this.fail(declaration.type, 'TypeRTLookupMap values must be TypeRTLookup') + } + const host = this.symbolAtType(hostType) + if (host === undefined) this.fail(hostType, 'TypeRTLookup Host must be a named type') + const entry: StaticLookupDeclaration = { + key, + hostSymbol: this.symbolId(host), + wireType, + site: declaration, + } + if (byKey.has(key)) this.fail(declaration, `duplicate TypeRTLookupMap key ${key}`) + if (byHost.has(entry.hostSymbol)) this.fail(declaration, `Host type ${host.name} has more than one TypeRT lookup`) + byKey.set(key, entry) + byHost.set(entry.hostSymbol, entry) + } + this.staticLookups = [...byKey.values()] + return this.staticLookups + } + + private contextDeclarations(): ReadonlyMap { + if (this.staticContexts !== undefined) return this.staticContexts + const result = new Map() + for (const declaration of this.typeMetaMapMembers('TypeRTContextMap')) { + if (!ts.isPropertySignature(declaration) || declaration.type === undefined) { + this.fail(declaration, 'TypeRTContextMap entries must be required properties') + } + const key = memberName(declaration.name) + if (!isRemoteSegment(key)) this.fail(declaration.name, 'TypeRTContextMap key must be nonempty and must not contain "/"') + if (!ts.isTypeReferenceNode(declaration.type) + || !this.isTypeMetaSymbol(declaration.type.typeName, 'TypeRTContext') + || declaration.type.typeArguments?.length !== 1) { + this.fail(declaration.type, 'TypeRTContextMap values must be TypeRTContext') + } + if (result.has(key)) this.fail(declaration, `duplicate TypeRTContextMap key ${key}`) + const wireType = declaration.type.typeArguments[0] + if (wireType === undefined) this.fail(declaration.type, 'TypeRTContextMap values must be TypeRTContext') + result.set(key, { + key, + wireType, + site: declaration, + }) + } + this.staticContexts = result + return result + } + + private typeMetaMapMembers(name: 'TypeRTLookupMap' | 'TypeRTContextMap'): ts.TypeElement[] { + const result: ts.TypeElement[] = [] + for (const sourceFile of this.program.getSourceFiles()) { + for (const statement of sourceFile.statements) { + if (!ts.isModuleDeclaration(statement) + || !ts.isStringLiteral(statement.name) + || statement.name.text !== '@deepseek-ai/dsh-type-meta' + || statement.body === undefined + || !ts.isModuleBlock(statement.body)) continue + for (const nested of statement.body.statements) { + if (ts.isInterfaceDeclaration(nested) && nested.name.text === name) result.push(...nested.members) + } + } + } + return result + } + + private remoteBoundary( + authoredType: ts.TypeNode, + fallbackTypeSymbol: string, + requireNamed: boolean, + ): RemoteBoundaryModel { + const type = this.convertType(authoredType) + const codecType = this.resolvedRemoteCodecType(authoredType) + const rootSymbol = this.namedWorkspaceType(authoredType) + if (rootSymbol !== undefined) { + const imported = this.publicRemoteType(rootSymbol, authoredType) + return { + type, + codecType, + typeSymbol: `${imported.specifier}#${imported.name}`, + imports: [imported], + } + } + if (requireNamed) this.fail(authoredType, 'lookup and Context wire types must be named public types') + const imports = new Map() + const visit = (node: ts.Node): void => { + if ((ts.isTypeReferenceNode(node) || ts.isImportTypeNode(node))) { + const symbol = ts.isTypeReferenceNode(node) + ? this.checker.getSymbolAtLocation(node.typeName) + : node.qualifier === undefined ? undefined : this.checker.getSymbolAtLocation(node.qualifier) + if (symbol !== undefined) { + const resolved = this.resolveSymbol(symbol) + const declaration = preferredDeclaration(resolved) + if (declaration !== undefined + && !isStandardLibraryFile(declaration.getSourceFile().fileName) + && this.registrationForFile(declaration.getSourceFile().fileName) !== undefined) { + const imported = this.publicRemoteType(resolved, node) + imports.set(imported.symbol, imported) + return + } + } + } + ts.forEachChild(node, visit) + } + visit(authoredType) + return { + type, + codecType, + typeSymbol: fallbackTypeSymbol, + imports: [...imports.values()].sort((left, right) => + left.specifier.localeCompare(right.specifier) || left.name.localeCompare(right.name)), + } + } + + /** + * Project one authored Remote boundary through the complete face Program. + * Consumer declarations retain the authored alias, while codecs use this + * concrete graph so declaration-merged mapped and conditional types are + * validated without teaching the compiler-independent emitter TypeScript's + * type evaluator. + */ + private resolvedRemoteCodecType(authoredType: ts.TypeNode): TypeNodeId { + const completed = new Map() + const active = new Map() + const recursiveDeclarations = new Map() + const convert = (type: ts.Type): TypeNodeId => { + const cached = completed.get(type) + if (cached !== undefined) return cached + const activeId = active.get(type) + if (activeId !== undefined) { + if (this.checker.isArrayType(type) || this.checker.isArrayLikeType(type)) { + const element = this.checker.getIndexTypeOfType(type, ts.IndexKind.Number) + const elementId = element === undefined ? undefined : active.get(element) + if (element !== undefined && elementId !== undefined) { + return this.addNode(authoredType, { + kind: 'array', + element: this.resolvedCycleReference( + element, + authoredType, + elementId, + recursiveDeclarations, + ), + }) + } + } + return this.resolvedCycleReference(type, authoredType, activeId, recursiveDeclarations) + } + const id = this.allocateNodeId(authoredType) + active.set(type, id) + try { + const add = (model: TypeNodeInput): TypeNodeId => { + this.nodes.set(id, { id, ...model }) + completed.set(type, id) + return id + } + const flags = type.flags + if ((flags & ts.TypeFlags.Any) !== 0) return add({ kind: 'keyword', name: 'any' }) + if ((flags & ts.TypeFlags.Unknown) !== 0) return add({ kind: 'keyword', name: 'unknown' }) + if ((flags & ts.TypeFlags.Never) !== 0) return add({ kind: 'keyword', name: 'never' }) + if ((flags & ts.TypeFlags.String) !== 0) return add({ kind: 'keyword', name: 'string' }) + if ((flags & ts.TypeFlags.Number) !== 0) return add({ kind: 'keyword', name: 'number' }) + if ((flags & ts.TypeFlags.BigInt) !== 0) return add({ kind: 'keyword', name: 'bigint' }) + if ((flags & ts.TypeFlags.Boolean) !== 0) return add({ kind: 'keyword', name: 'boolean' }) + if ((flags & ts.TypeFlags.ESSymbol) !== 0) return add({ kind: 'keyword', name: 'symbol' }) + if ((flags & ts.TypeFlags.Undefined) !== 0) return add({ kind: 'keyword', name: 'undefined' }) + if ((flags & ts.TypeFlags.Void) !== 0) return add({ kind: 'keyword', name: 'void' }) + if ((flags & ts.TypeFlags.Null) !== 0) return add({ kind: 'literal', value: null, text: 'null' }) + if ((flags & ts.TypeFlags.StringLiteral) !== 0) { + const value = (type as ts.StringLiteralType).value + return add({ kind: 'literal', value, text: JSON.stringify(value) }) + } + if ((flags & ts.TypeFlags.NumberLiteral) !== 0) { + const value = (type as ts.NumberLiteralType).value + return add({ kind: 'literal', value, text: String(value) }) + } + if ((flags & ts.TypeFlags.BigIntLiteral) !== 0) { + const value = (type as ts.BigIntLiteralType).value + const text = `${value.negative ? '-' : ''}${value.base10Value}n` + return add({ kind: 'literal', value: BigInt(`${value.negative ? '-' : ''}${value.base10Value}`), text }) + } + if ((flags & ts.TypeFlags.BooleanLiteral) !== 0) { + const value = (type as ts.Type & { readonly intrinsicName?: string }).intrinsicName === 'true' + return add({ kind: 'literal', value, text: String(value) }) + } + if (type.isUnionOrIntersection()) { + return add({ + kind: (flags & ts.TypeFlags.Union) !== 0 ? 'union' : 'intersection', + types: type.types.map(convert), + }) + } + if ((flags & ts.TypeFlags.TypeParameter) !== 0) { + this.fail(authoredType, 'Remote codec contains an unresolved type parameter') + } + if ((flags & ts.TypeFlags.Object) === 0) { + this.fail( + authoredType, + `Remote codec type ${this.checker.typeToString(type, authoredType, ts.TypeFormatFlags.NoTruncation)} has no concrete Zod projection`, + ) + } + if (this.checker.isTupleType(type)) { + const reference = type as ts.TypeReference + const target = reference.target as ts.TupleType + const arguments_ = this.checker.getTypeArguments(reference) + return add({ + kind: 'tuple', + elements: arguments_.map((argument, index) => { + const elementFlags = target.elementFlags[index] ?? ts.ElementFlags.Required + return { + type: convert(argument), + optional: (elementFlags & ts.ElementFlags.Optional) !== 0, + rest: (elementFlags & (ts.ElementFlags.Rest | ts.ElementFlags.Variadic)) !== 0, + } + }), + }) + } + if (this.checker.isArrayType(type) || this.checker.isArrayLikeType(type)) { + const element = this.checker.getIndexTypeOfType(type, ts.IndexKind.Number) + if (element === undefined) this.fail(authoredType, 'Remote codec array has no element type') + return add({ kind: 'array', element: convert(element) }) + } + if (type.getCallSignatures().length > 0 || type.getConstructSignatures().length > 0) { + this.fail(authoredType, 'Remote codec cannot contain callable or constructable values') + } + const members: MemberModel[] = [] + for (const property of this.checker.getPropertiesOfType(type)) { + const declaration = property.valueDeclaration ?? property.declarations?.[0] + const propertyType = this.checker.getTypeOfSymbolAtLocation(property, declaration ?? authoredType) + const symbolKey = property.getName() + members.push({ + ...EMPTY_DOCUMENTATION, + id: `${id}#${symbolKey}`, + name: symbolKey, + ...(symbolKey.startsWith('__@') ? { computed: 'symbol' as const } : {}), + optional: (property.flags & ts.SymbolFlags.Optional) !== 0, + readonly: declaration !== undefined && hasModifier(declaration, ts.SyntaxKind.ReadonlyKeyword), + async: false, + abstract: false, + static: false, + visibility: 'public', + location: this.location(authoredType), + text: '', + kind: 'property', + type: convert(propertyType), + }) + } + for (const [index, info] of this.checker.getIndexInfosOfType(type).entries()) { + members.push({ + ...EMPTY_DOCUMENTATION, + id: `${id}#index:${String(index)}`, + name: '(index)', + optional: false, + readonly: info.isReadonly, + async: false, + abstract: false, + static: false, + visibility: 'public', + location: this.location(authoredType), + text: '', + kind: 'index', + signature: { + typeParameters: [], + parameters: [{ + name: 'key', + binding: 'identifier', + type: convert(info.keyType), + optional: false, + rest: false, + receiver: false, + }], + returns: convert(info.type), + }, + }) + } + return add({ kind: 'object', members }) + } finally { + active.delete(type) + } + } + return convert(this.checker.getTypeFromTypeNode(authoredType)) + } + + private resolvedCycleReference( + type: ts.Type, + site: ts.TypeNode, + resolvedType: TypeNodeId, + recursiveDeclarations: Map, + ): TypeNodeId { + const symbol = type.aliasSymbol ?? type.getSymbol() + if (symbol === undefined) this.fail(site, 'Remote codec contains an unnamed recursive type') + const resolved = this.resolveSymbol(symbol) + const declaration = preferredDeclaration(resolved) + if (declaration === undefined || isStandardLibraryFile(declaration.getSourceFile().fileName)) { + this.fail(site, `Remote codec recursive type ${resolved.name} has no workspace declaration`) + } + const owner = this.registrationForFile(declaration.getSourceFile().fileName) + if (owner === undefined) this.fail(site, `Remote codec recursive type ${resolved.name} is not owned by this face`) + let id = recursiveDeclarations.get(type) + if (id === undefined) { + id = `${this.symbolId(resolved)}#remote-codec:${resolvedType}` + recursiveDeclarations.set(type, id) + this.declarations.set(id, { + ...EMPTY_DOCUMENTATION, + id, + package: owner.name, + name: `${resolved.name}RemoteCodec`, + kind: 'alias', + abstract: false, + exported: false, + location: this.location(declaration), + text: '', + typeParameters: [], + extends: [], + implements: [], + members: [], + type: resolvedType, + }) + } + return this.addNode(site, { + kind: 'reference', + name: `${resolved.name}RemoteCodec`, + target: { kind: 'declaration', symbol: id }, + arguments: [], + }) + } + + private namedWorkspaceType(node: ts.TypeNode): ts.Symbol | undefined { + if (!ts.isTypeReferenceNode(node) && !ts.isImportTypeNode(node)) return undefined + const symbol = ts.isTypeReferenceNode(node) + ? this.checker.getSymbolAtLocation(node.typeName) + : node.qualifier === undefined ? undefined : this.checker.getSymbolAtLocation(node.qualifier) + if (symbol === undefined) return undefined + const resolved = this.resolveSymbol(symbol) + const declaration = preferredDeclaration(resolved) + if (declaration === undefined + || isStandardLibraryFile(declaration.getSourceFile().fileName) + || this.registrationForFile(declaration.getSourceFile().fileName) === undefined) return undefined + return resolved + } + + private publicRemoteType(symbol: ts.Symbol, site: ts.Node): RemoteTypeImportModel { + const declaration = preferredDeclaration(symbol) + if (declaration === undefined) this.fail(site, `type ${symbol.name} has no declaration`) + const registration = this.registrationForFile(declaration.getSourceFile().fileName) + if (registration === undefined) this.fail(site, `type ${symbol.name} is not owned by a workspace package`) + const candidates: RemoteTypeImportModel[] = [] + for (const [subpath, target] of packageExportTargets(registration.manifest)) { + if (subpath === '.' || subpath === './package.json' || subpath === './typert' + || subpath === './client/typert' || subpath === './remote' || target.includes('*')) continue + const sourceFile = this.sourceFiles.get(realPath(sourcePathForExport(registration.root, target))) + if (sourceFile === undefined) continue + const moduleSymbol = this.checker.getSymbolAtLocation(sourceFile) + if (moduleSymbol === undefined) continue + for (const exported of this.checker.getExportsOfModule(moduleSymbol)) { + if (this.resolveSymbol(exported) !== symbol) continue + candidates.push({ + symbol: this.symbolId(symbol), + specifier: packageExportSpecifier(registration.name, subpath), + name: exported.name, + }) + } + } + const selected = candidates.sort((left, right) => + left.specifier.localeCompare(right.specifier) || left.name.localeCompare(right.name))[0] + if (selected === undefined) { + this.fail(site, `Remote boundary type ${symbol.name} must be exported from a public non-root type subpath`) + } + return selected + } + + private isWorkspaceClass(symbol: ts.Symbol): boolean { + const declaration = preferredDeclaration(symbol) + return declaration !== undefined + && ts.isClassDeclaration(declaration) + && this.registrationForFile(declaration.getSourceFile().fileName) !== undefined + } + + private isTypeMetaSymbol(node: ts.Node, name: string): boolean { + const symbol = this.checker.getSymbolAtLocation(node) + if (symbol === undefined) return false + const resolved = this.resolveSymbol(symbol) + if (resolved.name !== name) return false + const declaration = preferredDeclaration(resolved) + if (declaration === undefined) return false + const registration = this.registrationForFile(declaration.getSourceFile().fileName) + if (registration?.name === '@deepseek-ai/dsh-type-meta') return true + for (let current: ts.Node | undefined = declaration; current !== undefined; current = optionalParent(current)) { + if (ts.isModuleDeclaration(current) + && ts.isStringLiteral(current.name) + && current.name.text === '@deepseek-ai/dsh-type-meta') return true + } + return false + } + + private validateInvocationIdentity(packages: readonly PackageModel[]): void { + const endpoints = new Map() + const ids = new Map() + for (const invocation of packages.flatMap(packageModel => packageModel.invocations)) { + const endpoint = `${invocation.namespace}/${invocation.method}` + const existingEndpoint = endpoints.get(endpoint) + if (existingEndpoint !== undefined) { + throw new TypertAnalysisError( + `typert(${this.face}): ${invocation.location.file}:${String(invocation.location.line)}:${String(invocation.location.column)}: Remote endpoint ${endpoint} conflicts with ${existingEndpoint.id}`, + ) + } + const existingId = ids.get(invocation.id) + if (existingId !== undefined) { + throw new TypertAnalysisError( + `typert(${this.face}): ${invocation.location.file}:${String(invocation.location.line)}:${String(invocation.location.column)}: Remote invocation id ${invocation.id} conflicts with ${existingId.id}`, + ) + } + endpoints.set(endpoint, invocation) + ids.set(invocation.id, invocation) + } + } + private collectEvents(events: ts.InterfaceDeclaration): EventModel[] { const result: EventModel[] = [] for (const member of events.members) { @@ -1015,6 +1777,11 @@ class FaceAnalyzer { ): MemberModel[] { const result: MemberModel[] = [] for (const member of members) { + if (ts.isPropertyDeclaration(member) + && memberName(member.name) === 'typertGateway' + && member.initializer !== undefined + && ts.isCallExpression(member.initializer) + && this.isTypeMetaSymbol(member.initializer.expression, 'bindTypeRTGateway')) continue const visibility = visibilityOf(member) const isStatic = hasModifier(member, ts.SyntaxKind.StaticKeyword) if (visibility !== 'public' || isStatic || ts.isConstructorDeclaration(member)) continue @@ -1045,17 +1812,19 @@ class FaceAnalyzer { visibility: MemberVisibility, isStatic: boolean, ): MemberBase { - const name = member.name !== undefined - ? memberName(member.name) - : ts.isCallSignatureDeclaration(member) - ? '(call)' - : ts.isConstructSignatureDeclaration(member) - ? '(construct)' - : '(index)' + const identity = member.name !== undefined + ? this.memberIdentity(member.name) + : { + name: ts.isCallSignatureDeclaration(member) + ? '(call)' + : ts.isConstructSignatureDeclaration(member) + ? '(construct)' + : '(index)', + } return { ...documentationOf(member), - id: `${ownerId}#${name}@${String(member.getStart())}`, - name, + id: `${ownerId}#${identity.name}@${String(member.getStart())}`, + ...identity, optional: 'questionToken' in member && member.questionToken !== undefined, readonly: hasModifier(member, ts.SyntaxKind.ReadonlyKeyword), async: hasModifier(member, ts.SyntaxKind.AsyncKeyword), @@ -1067,6 +1836,20 @@ class FaceAnalyzer { } } + private memberIdentity(name: ts.PropertyName): Pick { + if (!ts.isComputedPropertyName(name)) return { name: memberName(name) } + const expression = name.expression + if (ts.isStringLiteral(expression) || ts.isNumericLiteral(expression) + || ts.isNoSubstitutionTemplateLiteral(expression)) { + return { name: memberName(name), jsonName: expression.text } + } + const type = this.checker.getTypeAtLocation(expression) + return { + name: memberName(name), + computed: (type.flags & ts.TypeFlags.UniqueESSymbol) !== 0 ? 'symbol' : 'dynamic', + } + } + private signature( node: ts.SignatureDeclarationBase, explicitReturn: ts.TypeNode | undefined, @@ -1570,7 +2353,23 @@ function sourceFileHasSurface(sourceFile: ts.SourceFile): boolean { || ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement) || ts.isEnumDeclaration(statement)) - && typertMode(statement) !== undefined) return true + && (typertMode(statement) !== undefined || typertServiceTag(statement) !== undefined)) return true + if (ts.isClassDeclaration(statement)) { + for (const member of statement.members) { + if (ts.isPropertyDeclaration(member) + && memberName(member.name) === 'typertGateway' + && member.initializer !== undefined + && ts.isCallExpression(member.initializer) + && expressionName(member.initializer.expression) === 'bindTypeRTGateway') return true + for (const decorator of ts.canHaveDecorators(member) ? ts.getDecorators(member) ?? [] : []) { + const expression = ts.isCallExpression(decorator.expression) + ? decorator.expression.expression + : decorator.expression + const name = expressionName(expression) + if (name === 'Remote' || name === 'RemoteContext') return true + } + } + } if (!ts.isModuleDeclaration(statement) || !ts.isStringLiteral(statement.name) || statement.name.text !== 'cordis' @@ -1588,6 +2387,7 @@ function hasPackageSurface(model: PackageModel): boolean { || model.events.length > 0 || model.objects.length > 0 || model.schemas.length > 0 + || model.invocations.length > 0 } function isDualFacePackage(manifest: Record): boolean { @@ -1599,7 +2399,9 @@ function isDualFacePackage(manifest: Record): boolean { function hostExportSubpaths(manifest: Record): string[] { return packageExportTargets(manifest) .map(([subpath]) => subpath) - .filter(subpath => subpath !== './client' && !subpath.startsWith('./client/')) + .filter(subpath => subpath !== './client' + && !subpath.startsWith('./client/') + && subpath !== './remote') } function clientExportSubpaths(manifest: Record): string[] { @@ -1668,6 +2470,10 @@ function preferredDeclaration(symbol: ts.Symbol): ts.Declaration | undefined { ?? symbol.declarations?.[0] } +function optionalParent(node: ts.Node): ts.Node | undefined { + return (node as ts.Node & { readonly parent?: ts.Node }).parent +} + function isTypeDeclaration( node: ts.Node, ): node is ts.ClassDeclaration | ts.InterfaceDeclaration | ts.TypeAliasDeclaration | ts.EnumDeclaration { @@ -1822,6 +2628,11 @@ function typertMode(node: ts.Node): 'object' | 'schema' | undefined { return undefined } +function typertServiceTag(node: ts.Node): ts.JSDocTag | undefined { + return ts.getJSDocTags(node).find(tag => tag.tagName.text === 'typert' + && (ts.getTextOfJSDocComment(tag.comment) ?? '').trim().split(/\s+/, 1)[0] === 'service') +} + function memberName(name: ts.PropertyName | ts.BindingName): string { if (ts.isIdentifier(name) || ts.isPrivateIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name) || ts.isNoSubstitutionTemplateLiteral(name)) return name.text @@ -1829,6 +2640,26 @@ function memberName(name: ts.PropertyName | ts.BindingName): string { return name.getText() } +function stringLiteralValue(node: ts.Node | undefined): string | undefined { + return node !== undefined && (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) + ? node.text + : undefined +} + +function isRemoteSegment(value: string): boolean { + return value.length > 0 && !value.includes('/') +} + +function expressionName(node: ts.Expression): string | undefined { + if (ts.isIdentifier(node)) return node.text + if (ts.isPropertyAccessExpression(node)) return node.name.text + return undefined +} + +function packageExportSpecifier(packageName: string, subpath: string): string { + return subpath === '.' ? packageName : `${packageName}${subpath.slice(1)}` +} + function visibilityOf(node: ts.Node): MemberVisibility { if ('name' in node && node.name !== undefined && ts.isPrivateIdentifier(node.name as ts.Node)) return 'private' if (hasModifier(node, ts.SyntaxKind.PrivateKeyword)) return 'private' diff --git a/packages/typert/generator/src/cordis-catalog.ts b/packages/typert/generator/src/cordis-catalog.ts index 1bcb1ca72a..e5c2c15a00 100644 --- a/packages/typert/generator/src/cordis-catalog.ts +++ b/packages/typert/generator/src/cordis-catalog.ts @@ -231,7 +231,7 @@ export class CordisCatalogProjector { for (const service of packageModel.services) { const declaration = this.renderer.declaration(service.symbol) if (declaration.kind !== 'class' - || !/^packages\/[^/]+\/[^/]+\/src\/index\.ts$/.test(service.location.file) + || !/^packages\/[^/]+\/[^/]+\/src\/[^/]+\.ts$/.test(service.location.file) || declaration.location.file !== service.location.file) continue const doc = parseJsDoc(declaration.jsDoc ?? '').doc const source = pointer(declaration.location) diff --git a/packages/typert/generator/src/emitter.ts b/packages/typert/generator/src/emitter.ts index 4a09eaad68..3e79780593 100644 --- a/packages/typert/generator/src/emitter.ts +++ b/packages/typert/generator/src/emitter.ts @@ -4,11 +4,17 @@ * @module @deepseek-ai/dsh-typert-generator/emitter */ +import { Buffer } from 'node:buffer' +import { posix } from 'node:path' +import { GenMapping, addMapping, toEncodedMap } from '@jridgewell/gen-mapping' import type { DocumentationModel, FaceModel, + InvocationModel, MemberModel, PackageModel, + RemoteBoundaryModel, + RemoteTypeImportModel, SchemaModel, SymbolId, TypeDeclarationModel, @@ -29,6 +35,14 @@ export interface ModelEmitResult { readonly exports: readonly string[] readonly js: string readonly dts: string + readonly remote?: RemoteModelEmitResult +} + +/** Host-for-Client Remote contribution generated from the Host Program. */ +export interface RemoteModelEmitResult { + readonly js: string + readonly dts: string + readonly dtsMap: string } interface RuntimeMemberModel { @@ -92,7 +106,11 @@ export class FaceModelEmitter { if (packageModel === undefined) { throw new TypertEmitError(`typert emitter(${this.face.face}): package ${packageName} is not modeled on this face`) } - const schemas = new SchemaEmitter(this.renderer, packageModel.schemas) + const schemas = new SchemaEmitter( + this.renderer, + packageModel.schemas, + invocationBoundaryRoots(packageModel.invocations), + ) const schemaArtifact = schemas.emit() const runtimeModel = this.runtimeModel(packageModel) const js = this.renderJs(packageModel, schemaArtifact, runtimeModel) @@ -103,6 +121,9 @@ export class FaceModelEmitter { exports: packageModel.schemas.map(schema => schema.export.name), js, dts, + ...(this.face.face === 'host' && packageModel.invocations.length > 0 + ? { remote: this.emitRemote(packageModel) } + : {}), } } @@ -184,6 +205,11 @@ export class FaceModelEmitter { lines.push(` { name: ${quote(schema.exportName)}, schema: ${schema.exportName} },`) } lines.push(' ],') + lines.push(' invocations: [') + for (const invocation of packageModel.invocations) { + lines.push(`${indent(this.invocationLiteral(invocation, schemas), 4)},`) + } + lines.push(' ],') lines.push(` model: ${indent(model, 2).trimStart()},`) lines.push('}') return `${lines.join('\n')}\n` @@ -215,6 +241,246 @@ export class FaceModelEmitter { lines.push('export declare const TYPERT: unknown') return `${lines.join('\n')}\n` } + + private emitRemote(packageModel: PackageModel): RemoteModelEmitResult { + const schemas = new SchemaEmitter( + this.renderer, + [], + invocationBoundaryRoots(packageModel.invocations), + ).emit() + const lines = [ + '/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */', + ] + if (schemas.definitions.length > 0) lines.push('import { z } from \'zod\'', '') + lines.push(...schemas.definitions) + if (schemas.definitions.length > 0) lines.push('') + lines.push('export const TYPERT_REMOTE = {') + lines.push(` package: ${quote(packageModel.name)},`) + lines.push(' descriptors: [') + for (const invocation of packageModel.invocations) { + lines.push(`${indent(this.invocationLiteral(invocation, schemas), 4)},`) + } + lines.push(' ],') + lines.push('}') + lines.push('') + lines.push('export default TYPERT_REMOTE') + const declaration = this.renderRemoteDts(packageModel) + return { + js: `${lines.join('\n')}\n`, + ...declaration, + } + } + + private invocationLiteral(invocation: InvocationModel, schemas: SchemaArtifact): string { + const lines = [ + '{', + ` id: ${quote(invocation.id)},`, + ` service: ${quote(invocation.service)},`, + ` namespace: ${quote(invocation.namespace)},`, + ` method: ${quote(invocation.method)},`, + ] + if (invocation.implementation !== undefined) { + lines.push(` implementation: ${quote(invocation.implementation)},`) + } + if (invocation.invocation.kind === 'direct') { + lines.push(' invocation: { kind: \'direct\' },') + } else { + lines.push(' invocation: {') + lines.push(' kind: \'context\',') + lines.push(` context: ${quote(invocation.invocation.context)},`) + lines.push(` wire: ${quote(invocation.invocation.wire)},`) + lines.push(` codec: ${indent(strictCodec( + invocation.invocation.boundary, + schemas.boundary(contextBoundaryKey(invocation)), + ), 4).trimStart()},`) + lines.push(' },') + } + if (invocation.scope !== undefined) { + lines.push(' scope: {') + lines.push(` context: ${quote(invocation.scope.context)},`) + lines.push(` wire: ${quote(invocation.scope.wire)},`) + lines.push(' },') + } + lines.push(' parameters: [') + invocation.parameters.forEach((parameter, index) => { + lines.push(' {') + lines.push(` name: ${quote(parameter.name)},`) + lines.push(` wire: ${quote(parameter.wire)},`) + lines.push(` source: ${quote(parameter.source)},`) + if (parameter.lookup !== undefined) lines.push(` lookup: ${quote(parameter.lookup)},`) + lines.push(` codec: ${indent(strictCodec( + parameter.boundary, + schemas.boundary(parameterBoundaryKey(invocation, index)), + ), 6).trimStart()},`) + lines.push(' },') + }) + lines.push(' ],') + lines.push(` result: ${indent(strictCodec( + invocation.result, + schemas.boundary(resultBoundaryKey(invocation)), + ), 2).trimStart()},`) + lines.push(` sourceLocation: ${JSON.stringify(invocation.location)},`) + lines.push('}') + return lines.join('\n') + } + + private renderRemoteDts(packageModel: PackageModel): Pick { + const imports = remoteImports(packageModel.invocations) + const referenceNames = allocateRemoteImportNames(imports) + const grouped = new Map() + for (const imported of imports) { + const values = grouped.get(imported.specifier) ?? [] + values.push({ + name: imported.name, + local: referenceNames.get(imported.symbol) as string, + }) + grouped.set(imported.specifier, values) + } + const lines = [ + '/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */', + 'import type {', + ' TypeRTRemoteContribution,', + '} from \'@deepseek-ai/dsh-type-meta\'', + ] + const sourceMap = new GenMapping({ file: 'typert.remote-client.d.ts' }) + for (const [specifier, values] of [...grouped].sort(([left], [right]) => left.localeCompare(right))) { + const names = values.sort((left, right) => left.local.localeCompare(right.local)).map(value => + value.name === value.local ? value.name : `${value.name} as ${value.local}`) + lines.push(`import type { ${names.join(', ')} } from ${quote(specifier)}`) + } + lines.push('') + lines.push('declare module \'@deepseek-ai/dsh-type-meta\' {') + const direct = packageModel.invocations.filter(invocation => invocation.invocation.kind === 'direct') + const scoped = packageModel.invocations.filter(invocation => + invocation.invocation.kind === 'context' || invocation.scope !== undefined) + if (direct.length > 0) { + for (const namespace of uniqueNamespaces(direct)) { + lines.push(` interface ${remoteNamespaceInterface(namespace)} {`) + for (const invocation of direct.filter(candidate => candidate.namespace === namespace)) { + this.pushRemoteNamespaceSignature(lines, sourceMap, packageModel, invocation, referenceNames) + } + lines.push(' }') + } + lines.push(' interface TypeRTRemoteMap {') + for (const invocation of direct) { + this.pushRemoteSignature(lines, sourceMap, packageModel, invocation, referenceNames, false) + } + lines.push(' }') + lines.push(' interface TypeRTRemoteNamespaceMap {') + for (const namespace of uniqueNamespaces(direct)) { + lines.push(` ${quote(namespace)}: ${remoteNamespaceInterface(namespace)}`) + } + lines.push(' }') + } + if (scoped.length > 0) { + lines.push(' interface TypeRTRemoteContextMap {') + for (const invocation of scoped) { + this.pushRemoteSignature(lines, sourceMap, packageModel, invocation, referenceNames, true) + } + lines.push(' }') + } + lines.push('}') + lines.push('') + lines.push('export declare const TYPERT_REMOTE: TypeRTRemoteContribution') + lines.push('export default TYPERT_REMOTE') + lines.push('//# sourceMappingURL=typert.remote-client.d.ts.map') + return { + dts: `${lines.join('\n')}\n`, + dtsMap: `${JSON.stringify(toEncodedMap(sourceMap))}\n`, + } + } + + private pushRemoteSignature( + lines: string[], + sourceMap: GenMapping, + packageModel: PackageModel, + invocation: InvocationModel, + referenceNames: ReadonlyMap, + scoped: boolean, + ): void { + const signature = this.remoteSignature(invocation, referenceNames, scoped) + const line = ` ${signature}` + lines.push(line) + const generatedLine = lines.length + const keyLength = signature.indexOf(': (') + if (keyLength < 0) throw new TypertEmitError(`Remote signature ${invocation.id} has no property delimiter`) + const source = remoteDeclarationSource(packageModel, invocation) + addMapping(sourceMap, { + generated: { line: generatedLine, column: 4 }, + source, + original: { line: invocation.location.line, column: invocation.location.column - 1 }, + name: invocation.method, + }) + addMapping(sourceMap, { + generated: { line: generatedLine, column: 4 + keyLength }, + }) + } + + private pushRemoteNamespaceSignature( + lines: string[], + sourceMap: GenMapping, + packageModel: PackageModel, + invocation: InvocationModel, + referenceNames: ReadonlyMap, + ): void { + const signature = `${invocation.method}: ${this.remoteFunctionType(invocation, referenceNames, false)}` + lines.push(` ${signature}`) + const generatedLine = lines.length + const source = remoteDeclarationSource(packageModel, invocation) + addMapping(sourceMap, { + generated: { line: generatedLine, column: 4 }, + source, + original: { line: invocation.location.line, column: invocation.location.column - 1 }, + name: invocation.method, + }) + addMapping(sourceMap, { + generated: { line: generatedLine, column: 4 + invocation.method.length }, + }) + } + + private remoteSignature( + invocation: InvocationModel, + referenceNames: ReadonlyMap, + scoped: boolean, + ): string { + const context = invocation.invocation.kind === 'context' + ? invocation.invocation.context + : invocation.scope?.context + const key = scoped + ? `${context as string}:${invocation.namespace}/${invocation.method}` + : `${invocation.namespace}/${invocation.method}` + return `${quote(key)}: ${this.remoteFunctionType(invocation, referenceNames, scoped)}` + } + + private remoteFunctionType( + invocation: InvocationModel, + referenceNames: ReadonlyMap, + scoped: boolean, + ): string { + const parameters = invocation.parameters.filter(parameter => + !scoped || invocation.invocation.kind === 'context' || parameter.wire !== invocation.scope?.wire).map(parameter => + `${safeIdentifier(parameter.wire)}: ${this.renderer.renderType(parameter.boundary.type, referenceNames)}`) + const result = this.renderer.renderType(invocation.result.type, referenceNames) + return `(${parameters.join(', ')}) => Promise<${result}>` + } +} + +function remoteDeclarationSource(packageModel: PackageModel, invocation: InvocationModel): string { + const relativeSource = posix.relative(packageModel.root, invocation.location.file) + if (relativeSource === '' || relativeSource === '..' || relativeSource.startsWith('../') || posix.isAbsolute(relativeSource)) { + throw new TypertEmitError( + `Remote declaration ${invocation.id} is outside its package root ${packageModel.root}`, + ) + } + return posix.join('..', relativeSource) +} + +function uniqueNamespaces(invocations: readonly InvocationModel[]): string[] { + return [...new Set(invocations.map(invocation => invocation.namespace))].sort() +} + +function remoteNamespaceInterface(namespace: string): string { + return `TypeRTRemoteNamespace$${Buffer.from(namespace, 'utf8').toString('hex')}` } interface SchemaExport { @@ -226,15 +492,23 @@ interface SchemaExport { interface SchemaArtifact { readonly definitions: readonly string[] readonly exports: readonly SchemaExport[] + boundary(key: string): string +} + +interface BoundarySchemaRoot { + readonly key: string + readonly type: TypeNodeId } class SchemaEmitter { private readonly names = new Map() + private readonly boundaryNames = new Map() private readonly declarations: TypeDeclarationModel[] constructor( private readonly renderer: TypeGraphRenderer, private readonly schemas: readonly SchemaModel[], + private readonly boundaries: readonly BoundarySchemaRoot[], ) { const declarations = new Map() for (const schema of schemas) { @@ -242,6 +516,11 @@ class SchemaEmitter { declarations.set(declaration.id, declaration) } } + for (const boundary of boundaries) { + for (const declaration of renderer.declarationClosureForTypes([boundary.type])) { + declarations.set(declaration.id, declaration) + } + } this.declarations = renderer.graph.declarations.filter(declaration => declarations.has(declaration.id)) const identifiers = new Set() for (const declaration of this.declarations) { @@ -252,65 +531,92 @@ class SchemaEmitter { identifiers.add(name) this.names.set(declaration.id, name) } + for (const boundary of boundaries) { + const base = `${safeIdentifier(boundary.key)}$schema` + let name = base + let suffix = 2 + while (identifiers.has(name)) name = `${base}${String(suffix++)}` + identifiers.add(name) + this.boundaryNames.set(boundary.key, name) + } } emit(): SchemaArtifact { - const definitions = this.declarations.map((declaration) => { - if (declaration.typeParameters.length > 0) { - this.fail(declaration.name, 'generic declarations require a schema-factory projection') - } - return `const ${this.schemaName(declaration.id)} = ${this.declarationSchema(declaration)}` - }) + const definitions = this.declarations.map(declaration => this.declarationDefinition(declaration)) + for (const boundary of this.boundaries) { + definitions.push(`const ${this.boundaryName(boundary.key)} = ${this.typeSchema(boundary.type)}`) + } const exports = this.schemas.map((model): SchemaExport => ({ model, exportName: safeIdentifier(model.export.name), - internalName: this.schemaName(model.symbol), + internalName: this.exportSchemaName(model), })) - return { definitions, exports } + return { + definitions, + exports, + boundary: key => this.boundaryName(key), + } } - private declarationSchema(declaration: TypeDeclarationModel): string { + private declarationDefinition(declaration: TypeDeclarationModel): string { + const name = this.schemaName(declaration.id) + if (declaration.typeParameters.length === 0) { + return `const ${name} = ${this.declarationSchema(declaration, new Map())}` + } + const parameters = declaration.typeParameters.map((parameter, index) => + [`type${String(index)}$schema`, parameter.id] as const) + const substitutions = new Map(parameters.map(([schema, id]) => [id, schema])) + return `const ${name} = (${parameters.map(([schema]) => schema).join(', ')}) => ${this.declarationSchema(declaration, substitutions)}` + } + + private declarationSchema( + declaration: TypeDeclarationModel, + substitutions: ReadonlyMap, + ): string { if (declaration.kind === 'enum') { this.fail(declaration.name, 'enum declarations have no Zod projection') } if (declaration.kind === 'alias') { if (declaration.type === undefined) this.fail(declaration.name, 'alias has no modeled type') - return this.describe(this.typeSchema(declaration.type), declaration) + return this.describe(this.typeSchema(declaration.type, substitutions), declaration) } - const own = this.objectSchema(declaration.members, declaration.name) + const own = this.objectSchema(declaration.members, declaration.name, substitutions) let result = own for (const heritage of declaration.extends) { - result = `z.intersection(${this.typeSchema(heritage)}, ${result})` + result = `z.intersection(${this.typeSchema(heritage, substitutions)}, ${result})` } return this.describe(result, declaration) } - private typeSchema(id: TypeNodeId): string { + private typeSchema(id: TypeNodeId, substitutions: ReadonlyMap = new Map()): string { const node = this.renderer.node(id) switch (node.kind) { case 'keyword': return this.keywordSchema(node.name) case 'literal': return `z.literal(${node.text})` - case 'parenthesized': return this.typeSchema(node.type) - case 'reference': return this.referenceSchema(node) + case 'parenthesized': return this.typeSchema(node.type, substitutions) + case 'reference': return this.referenceSchema(node, substitutions) case 'union': { if (node.types.length === 0) return 'z.never()' - if (node.types.length === 1) return this.typeSchema(node.types[0] as TypeNodeId) - return `z.union([${node.types.map(type => this.typeSchema(type)).join(', ')}])` + if (node.types.length === 1) return this.typeSchema(node.types[0] as TypeNodeId, substitutions) + return `z.union([${node.types.map(type => this.typeSchema(type, substitutions)).join(', ')}])` } case 'intersection': { const [head, ...tail] = node.types if (head === undefined) return 'z.unknown()' - return tail.reduce((left, right) => `z.intersection(${left}, ${this.typeSchema(right)})`, this.typeSchema(head)) + return tail.reduce( + (left, right) => `z.intersection(${left}, ${this.typeSchema(right, substitutions)})`, + this.typeSchema(head, substitutions), + ) } - case 'array': return `z.array(${this.typeSchema(node.element)})` + case 'array': return `z.array(${this.typeSchema(node.element, substitutions)})` case 'tuple': { const fixed = node.elements.filter(element => !element.rest) const rest = node.elements.find(element => element.rest) - let schema = `z.tuple([${fixed.map(element => this.optional(this.typeSchema(element.type), element.optional)).join(', ')}])` - if (rest !== undefined) schema += `.rest(${this.tupleRestSchema(rest.type)})` + let schema = `z.tuple([${fixed.map(element => this.optional(this.typeSchema(element.type, substitutions), element.optional)).join(', ')}])` + if (rest !== undefined) schema += `.rest(${this.tupleRestSchema(rest.type, substitutions)})` return schema } - case 'object': return this.objectSchema(node.members, id) + case 'object': return this.objectSchema(node.members, id, substitutions) case 'operator': case 'indexed-access': case 'conditional': @@ -326,9 +632,27 @@ class SchemaEmitter { } } - private referenceSchema(node: Extract): string { + private referenceSchema( + node: Extract, + substitutions: ReadonlyMap, + ): string { if (node.target.kind === 'declaration') { - return `z.lazy(() => ${this.schemaName(node.target.symbol)})` + const name = this.schemaName(node.target.symbol) + const declaration = this.renderer.declaration(node.target.symbol) + if (declaration.typeParameters.length === 0) { + if (node.arguments.length > 0) { + this.fail(node.name, `non-generic declaration received ${String(node.arguments.length)} type arguments`) + } + return `z.lazy(() => ${name})` + } + const arguments_ = this.declarationArguments(node, declaration, substitutions) + return `z.lazy(() => ${name}(${arguments_.join(', ')}))` + } + if (node.target.kind === 'type-parameter') { + if (node.arguments.length > 0) this.fail(node.name, 'type parameter reference cannot receive type arguments') + const schema = substitutions.get(node.target.parameter) + if (schema === undefined) this.fail(node.name, 'type parameter has no schema substitution') + return schema } if (node.target.kind === 'standard') { switch (node.target.name) { @@ -336,13 +660,16 @@ class SchemaEmitter { case 'ReadonlyArray': { const element = node.arguments[0] if (element === undefined) this.fail(node.name, 'array reference has no element type') - return this.readonly(`z.array(${this.typeSchema(element)})`, node.target.name === 'ReadonlyArray') + return this.readonly( + `z.array(${this.typeSchema(element, substitutions)})`, + node.target.name === 'ReadonlyArray', + ) } case 'Record': { const key = node.arguments[0] const value = node.arguments[1] if (key === undefined || value === undefined) this.fail(node.name, 'Record requires key and value types') - return `z.record(${this.typeSchema(key)}, ${this.typeSchema(value)})` + return `z.record(${this.typeSchema(key, substitutions)}, ${this.typeSchema(value, substitutions)})` } case 'Date': return 'z.date()' default: this.fail(node.name, `standard type ${node.target.name} has no Zod projection`) @@ -351,31 +678,97 @@ class SchemaEmitter { this.fail(node.name, `${node.target.kind} reference has no Zod projection`) } - private tupleRestSchema(id: TypeNodeId): string { + private declarationArguments( + node: Extract, + declaration: TypeDeclarationModel, + substitutions: ReadonlyMap, + ): string[] { + if (node.arguments.length > declaration.typeParameters.length) { + this.fail( + node.name, + `generic declaration accepts ${String(declaration.typeParameters.length)} type arguments but received ${String(node.arguments.length)}`, + ) + } + const resolved = new Map(substitutions) + const arguments_: string[] = [] + for (const [index, parameter] of declaration.typeParameters.entries()) { + const argument = node.arguments[index] + const schema = argument === undefined + ? parameter.default === undefined + ? this.fail(node.name, `missing type argument ${parameter.name}`) + : this.typeSchema(parameter.default, resolved) + : this.typeSchema(argument, substitutions) + arguments_.push(schema) + resolved.set(parameter.id, schema) + } + return arguments_ + } + + private tupleRestSchema(id: TypeNodeId, substitutions: ReadonlyMap): string { const node = this.renderer.node(id) - if (node.kind === 'array') return this.typeSchema(node.element) + if (node.kind === 'array') return this.typeSchema(node.element, substitutions) if (node.kind === 'reference' && node.target.kind === 'standard' && (node.target.name === 'Array' || node.target.name === 'ReadonlyArray')) { const element = node.arguments[0] if (element === undefined) this.fail(node.name, 'tuple rest array has no element type') - return this.typeSchema(element) + return this.typeSchema(element, substitutions) } this.fail(id, 'tuple rest element must retain an array type') } - private objectSchema(members: readonly MemberModel[], subject: string): string { + private objectSchema( + members: readonly MemberModel[], + subject: string, + substitutions: ReadonlyMap, + ): string { const properties: string[] = [] + const indices: string[] = [] + let symbolMembers = 0 for (const member of members) { if (member.static || member.visibility !== 'public') continue + if (member.computed === 'symbol') { + symbolMembers++ + continue + } + if (member.computed === 'dynamic') { + this.fail(subject, `computed member ${member.name} has no fixed JSON property name`) + } + if (member.kind === 'index') { + const parameter = member.signature.parameters[0] + if (member.signature.parameters.length !== 1 || parameter === undefined) { + this.fail(subject, 'index signature must have exactly one key parameter') + } + indices.push(this.readonly( + `z.record(${this.typeSchema(parameter.type, substitutions)}, ${this.typeSchema(member.signature.returns, substitutions)})`, + member.readonly, + )) + continue + } if (member.kind !== 'property') this.fail(subject, `${member.kind} member ${member.name} is not data-schema projectable`) const property = this.describe( - this.optional(this.readonly(this.typeSchema(member.type), member.readonly), member.optional), + this.optional(this.readonly(this.typeSchema(member.type, substitutions), member.readonly), member.optional), member, ) - properties.push(`${quote(member.name)}: ${property}`) + properties.push(`${quote(member.jsonName ?? member.name)}: ${property}`) } - return `z.object({${properties.length === 0 ? '' : `\n${properties.map(property => ` ${property},`).join('\n')}\n`}})` + if (indices.length > 1) this.fail(subject, 'object type has more than one JSON index signature') + // A unique-symbol-only object is a compile-time marker and imposes no JSON shape. + if (properties.length === 0 && indices.length === 0 && symbolMembers > 0) return 'z.unknown()' + const object = `z.object({${properties.length === 0 ? '' : `\n${properties.map(property => ` ${property},`).join('\n')}\n`}})` + const index = indices[0] + if (index === undefined) return object + if (properties.length === 0) return index + return `z.intersection(${object}, ${index})` + } + + private exportSchemaName(model: SchemaModel): string { + const name = this.schemaName(model.symbol) + const declaration = this.renderer.declaration(model.symbol) + if (declaration.typeParameters.length > 0) { + this.fail(model.export.name, 'generic schema exports require a concrete declaration') + } + return name } private keywordSchema(name: string): string { @@ -401,6 +794,12 @@ class SchemaEmitter { return name } + private boundaryName(key: string): string { + const name = this.boundaryNames.get(key) + if (name === undefined) this.fail(key, 'invocation boundary is outside the selected schema roots') + return name + } + private describe(schema: string, documentation: DocumentationModel): string { return documentation.description === undefined ? schema : `${schema}.describe(${quote(documentation.description)})` } @@ -431,6 +830,77 @@ function documentationLiteral(documentation: DocumentationModel): DocumentationM } } +function invocationBoundaryRoots(invocations: readonly InvocationModel[]): BoundarySchemaRoot[] { + const result: BoundarySchemaRoot[] = [] + for (const invocation of invocations) { + if (invocation.invocation.kind === 'context') { + result.push({ key: contextBoundaryKey(invocation), type: invocation.invocation.boundary.codecType }) + } + invocation.parameters.forEach((parameter, index) => { + result.push({ key: parameterBoundaryKey(invocation, index), type: parameter.boundary.codecType }) + }) + result.push({ key: resultBoundaryKey(invocation), type: invocation.result.codecType }) + } + return result +} + +function contextBoundaryKey(invocation: InvocationModel): string { + return `${invocation.id}:context` +} + +function parameterBoundaryKey(invocation: InvocationModel, index: number): string { + return `${invocation.id}:parameter:${String(index)}` +} + +function resultBoundaryKey(invocation: InvocationModel): string { + return `${invocation.id}:result` +} + +function strictCodec(boundary: RemoteBoundaryModel, schema: string): string { + return [ + '{', + ' mode: \'strict\',', + ` typeSymbol: ${quote(boundary.typeSymbol)},`, + ` schema: ${schema},`, + '}', + ].join('\n') +} + +function remoteImports(invocations: readonly InvocationModel[]): RemoteTypeImportModel[] { + const imports = new Map() + const add = (boundary: RemoteBoundaryModel): void => { + for (const imported of boundary.imports) { + const current = imports.get(imported.symbol) + if (current !== undefined + && (current.specifier !== imported.specifier || current.name !== imported.name)) { + throw new TypertEmitError(`typert Remote emitter: symbol ${imported.symbol} has inconsistent public imports`) + } + imports.set(imported.symbol, imported) + } + } + for (const invocation of invocations) { + if (invocation.invocation.kind === 'context') add(invocation.invocation.boundary) + for (const parameter of invocation.parameters) add(parameter.boundary) + add(invocation.result) + } + return [...imports.values()].sort((left, right) => + left.specifier.localeCompare(right.specifier) || left.name.localeCompare(right.name)) +} + +function allocateRemoteImportNames(imports: readonly RemoteTypeImportModel[]): ReadonlyMap { + const used = new Set(['TypeRTRemoteContribution', 'TYPERT_REMOTE']) + const names = new Map() + for (const imported of imports) { + const base = safeIdentifier(imported.name) + let name = base + let suffix = 2 + while (used.has(name)) name = `${base}$remote${String(suffix++)}` + used.add(name) + names.set(imported.symbol, name) + } + return names +} + function packageExportSpecifier(packageName: string, subpath: string): string { return subpath === '.' ? packageName : `${packageName}${subpath.slice(1)}` } diff --git a/packages/typert/generator/src/model.ts b/packages/typert/generator/src/model.ts index c6b7ffbc87..7f15c8407c 100644 --- a/packages/typert/generator/src/model.ts +++ b/packages/typert/generator/src/model.ts @@ -94,6 +94,56 @@ export interface SchemaModel extends DocumentationModel { readonly type: TypeNodeId } +/** One public business type import retained for a generated Remote declaration. */ +export interface RemoteTypeImportModel { + readonly symbol: SymbolId + readonly specifier: string + readonly name: string +} + +/** One strict wire boundary and the public symbols needed to name it. */ +export interface RemoteBoundaryModel { + /** Authored public type retained for generated consumer declarations. */ + readonly type: TypeNodeId + /** Checker-resolved projection used only to emit the runtime codec. */ + readonly codecType: TypeNodeId + readonly typeSymbol: string + readonly imports: readonly RemoteTypeImportModel[] +} + +/** One ordered business argument projected onto a Remote wire field. */ +export interface InvocationParameterModel { + readonly name: string + readonly wire: string + readonly source: 'json' | 'lookup' + readonly lookup?: string + readonly boundary: RemoteBoundaryModel +} + +/** One strictly analyzed Host method exported through TypeRT Gateway. */ +export interface InvocationModel { + readonly id: string + readonly service: string + readonly namespace: string + readonly method: string + readonly implementation?: string + readonly invocation: + | { readonly kind: 'direct' } + | { + readonly kind: 'context' + readonly context: string + readonly wire: string + readonly boundary: RemoteBoundaryModel + } + readonly scope?: { + readonly context: string + readonly wire: string + } + readonly parameters: readonly InvocationParameterModel[] + readonly result: RemoteBoundaryModel + readonly location: SourceLocation +} + /** Business semantics discovered in one package on one face. */ export interface PackageModel { readonly name: string @@ -103,6 +153,7 @@ export interface PackageModel { readonly events: readonly EventModel[] readonly objects: readonly ObjectModel[] readonly schemas: readonly SchemaModel[] + readonly invocations: readonly InvocationModel[] } /** One explicit import/re-export edge between independently compiled faces. */ @@ -173,6 +224,10 @@ export interface SignatureModel { export interface MemberBase extends DocumentationModel { readonly id: string readonly name: string + /** JSON property name when a literal computed key differs from source text. */ + readonly jsonName?: string + /** Non-literal computed keys; symbol keys are erased from JSON schemas. */ + readonly computed?: 'symbol' | 'dynamic' readonly optional: boolean readonly readonly: boolean readonly async: boolean diff --git a/packages/typert/generator/src/renderer.ts b/packages/typert/generator/src/renderer.ts index 8d9a3c4954..5c6fc5cb2b 100644 --- a/packages/typert/generator/src/renderer.ts +++ b/packages/typert/generator/src/renderer.ts @@ -81,32 +81,35 @@ export class TypeGraphRenderer { /** * Render one type expression from the retained source structure. * @param id - type node id. + * @param references - optional generated names for declaration references. * @returns TypeScript type text. */ - renderType(id: TypeNodeId): string { + renderType(id: TypeNodeId, references?: ReadonlyMap): string { const node = this.node(id) switch (node.kind) { case 'keyword': return node.name case 'literal': return node.text - case 'parenthesized': return `(${this.renderType(node.type)})` + case 'parenthesized': return `(${this.renderType(node.type, references)})` case 'reference': { const name = node.target.kind === 'type-parameter' ? this.parameterNames.get(node.target.parameter) ?? node.name - : node.name + : node.target.kind === 'declaration' + ? references?.get(node.target.symbol) ?? node.name + : node.name return node.arguments.length === 0 ? name - : `${name}<${node.arguments.map(argument => this.renderType(argument)).join(', ')}>` + : `${name}<${node.arguments.map(argument => this.renderType(argument, references)).join(', ')}>` } - case 'union': return node.types.map(type => this.renderType(type)).join(' | ') - case 'intersection': return node.types.map(type => this.renderType(type)).join(' & ') + case 'union': return node.types.map(type => this.renderType(type, references)).join(' | ') + case 'intersection': return node.types.map(type => this.renderType(type, references)).join(' & ') case 'array': { - const element = this.renderType(node.element) + const element = this.renderType(node.element, references) const wrapped = needsArrayParentheses(this.node(node.element)) ? `(${element})` : element return `${wrapped}[]` } case 'tuple': { const elements = node.elements.map((element) => { - const type = this.renderType(element.type) + const type = this.renderType(element.type, references) if (element.name !== undefined) { return `${element.rest ? '...' : ''}${element.name}${element.optional ? '?' : ''}: ${type}` } @@ -114,34 +117,34 @@ export class TypeGraphRenderer { }) return `[${elements.join(', ')}]` } - case 'object': return this.renderObject(node.members) - case 'function': return `${this.renderSignatureHead(node.signature)} => ${this.renderType(node.signature.returns)}` - case 'constructor': return `${node.abstract ? 'abstract ' : ''}new ${this.renderSignatureHead(node.signature)} => ${this.renderType(node.signature.returns)}` - case 'indexed-access': return `${this.renderType(node.object)}[${this.renderType(node.index)}]` - case 'operator': return `${node.operator} ${this.renderType(node.type)}` + case 'object': return this.renderObject(node.members, references) + case 'function': return `${this.renderSignatureHead(node.signature, references)} => ${this.renderType(node.signature.returns, references)}` + case 'constructor': return `${node.abstract ? 'abstract ' : ''}new ${this.renderSignatureHead(node.signature, references)} => ${this.renderType(node.signature.returns, references)}` + case 'indexed-access': return `${this.renderType(node.object, references)}[${this.renderType(node.index, references)}]` + case 'operator': return `${node.operator} ${this.renderType(node.type, references)}` case 'conditional': { - return `${this.renderType(node.check)} extends ${this.renderType(node.extends)} ? ${this.renderType(node.whenTrue)} : ${this.renderType(node.whenFalse)}` + return `${this.renderType(node.check, references)} extends ${this.renderType(node.extends, references)} ? ${this.renderType(node.whenTrue, references)} : ${this.renderType(node.whenFalse, references)}` } - case 'infer': return `infer ${this.renderTypeParameter(node.parameter, false)}` + case 'infer': return `infer ${this.renderTypeParameter(node.parameter, false, references)}` case 'mapped': { const readonly = node.readonly === 'preserve' ? '' : node.readonly === 'remove' ? '-readonly ' : 'readonly ' const optional = node.optional === 'preserve' ? '' : node.optional === 'remove' ? '-?' : '?' if (node.parameter.constraint === undefined) { throw new TypeGraphRenderError(`mapped type parameter ${node.parameter.name} has no constraint`) } - const parameter = `${node.parameter.name} in ${this.renderType(node.parameter.constraint)}` - const nameType = node.nameType === undefined ? '' : ` as ${this.renderType(node.nameType)}` - const value = node.value === undefined ? 'unknown' : this.renderType(node.value) + const parameter = `${node.parameter.name} in ${this.renderType(node.parameter.constraint, references)}` + const nameType = node.nameType === undefined ? '' : ` as ${this.renderType(node.nameType, references)}` + const value = node.value === undefined ? 'unknown' : this.renderType(node.value, references) return `{ ${readonly}[${parameter}${nameType}]${optional}: ${value} }` } case 'template-literal': { - const spans = node.spans.map(span => `\${${this.renderType(span.type)}}${escapeTemplate(span.text)}`).join('') + const spans = node.spans.map(span => `\${${this.renderType(span.type, references)}}${escapeTemplate(span.text)}`).join('') return `\`${escapeTemplate(node.head)}${spans}\`` } case 'type-query': { const argumentsText = node.arguments.length === 0 ? '' - : `<${node.arguments.map(argument => this.renderType(argument)).join(', ')}>` + : `<${node.arguments.map(argument => this.renderType(argument, references)).join(', ')}>` return `typeof ${node.expression}${argumentsText}` } case 'import-type': { @@ -149,14 +152,14 @@ export class TypeGraphRenderer { const imported = `import(${quote(node.module)}${attributes})${node.qualifier === undefined ? '' : `.${node.qualifier}`}` const argumentsText = node.arguments.length === 0 ? '' - : `<${node.arguments.map(argument => this.renderType(argument)).join(', ')}>` + : `<${node.arguments.map(argument => this.renderType(argument, references)).join(', ')}>` return `${node.typeof ? 'typeof ' : ''}${imported}${argumentsText}` } case 'predicate': { const assertion = node.asserts ? 'asserts ' : '' return node.type === undefined ? `${assertion}${node.parameter}` - : `${assertion}${node.parameter} is ${this.renderType(node.type)}` + : `${assertion}${node.parameter} is ${this.renderType(node.type, references)}` } case 'this': return 'this' default: return assertNever(node) @@ -166,34 +169,36 @@ export class TypeGraphRenderer { /** * Render a callable signature without a member name. * @param signature - modeled signature. + * @param references - optional generated names for declaration references. * @returns parameter list and return type. */ - renderSignature(signature: SignatureModel): string { - return `${this.renderSignatureHead(signature)}: ${this.renderType(signature.returns)}` + renderSignature(signature: SignatureModel, references?: ReadonlyMap): string { + return `${this.renderSignatureHead(signature, references)}: ${this.renderType(signature.returns, references)}` } /** * Render one class/interface member as a body-free declaration. * @param member - modeled member. * @param sourceModifiers - retain source-only modifiers for reflection text. + * @param references - optional generated names for declaration references. * @returns one-line TypeScript member text. */ - renderMember(member: MemberModel, sourceModifiers = false): string { + renderMember(member: MemberModel, sourceModifiers = false, references?: ReadonlyMap): string { if (sourceModifiers) return member.text const name = renderPropertyName(member.name) const optional = member.optional ? '?' : '' const readonly = member.readonly ? 'readonly ' : '' const abstract = member.abstract ? 'abstract ' : '' switch (member.kind) { - case 'property': return `${abstract}${readonly}${name}${optional}: ${this.renderType(member.type)}` - case 'method': return `${abstract}${name}${optional}${this.renderSignature(member.signature)}` - case 'getter': return `${abstract}get ${name}()${this.renderReturn(member.signature)}` - case 'setter': return `${abstract}set ${name}${this.renderSignatureHead(member.signature)}` - case 'call': return this.renderSignature(member.signature) - case 'construct': return `new ${this.renderSignature(member.signature)}` + case 'property': return `${abstract}${readonly}${name}${optional}: ${this.renderType(member.type, references)}` + case 'method': return `${abstract}${name}${optional}${this.renderSignature(member.signature, references)}` + case 'getter': return `${abstract}get ${name}()${this.renderReturn(member.signature, references)}` + case 'setter': return `${abstract}set ${name}${this.renderSignatureHead(member.signature, references)}` + case 'call': return this.renderSignature(member.signature, references) + case 'construct': return `new ${this.renderSignature(member.signature, references)}` case 'index': { - const parameters = member.signature.parameters.map(parameter => this.renderParameter(parameter)).join(', ') - return `${readonly}[${parameters}]: ${this.renderType(member.signature.returns)}` + const parameters = member.signature.parameters.map(parameter => this.renderParameter(parameter, references)).join(', ') + return `${readonly}[${parameters}]: ${this.renderType(member.signature.returns, references)}` } default: return assertNever(member) } @@ -290,38 +295,42 @@ export class TypeGraphRenderer { return this.graph.declarations.filter(declaration => found.has(declaration.id)) } - private renderSignatureHead(signature: SignatureModel): string { - return `${this.renderTypeParameters(signature.typeParameters)}(${signature.parameters.map(parameter => this.renderParameter(parameter)).join(', ')})` + private renderSignatureHead(signature: SignatureModel, references?: ReadonlyMap): string { + return `${this.renderTypeParameters(signature.typeParameters, references)}(${signature.parameters.map(parameter => this.renderParameter(parameter, references)).join(', ')})` } - private renderReturn(signature: SignatureModel): string { - return `: ${this.renderType(signature.returns)}` + private renderReturn(signature: SignatureModel, references?: ReadonlyMap): string { + return `: ${this.renderType(signature.returns, references)}` } - private renderParameter(parameter: ParameterModel): string { + private renderParameter(parameter: ParameterModel, references?: ReadonlyMap): string { const name = parameter.binding === 'identifier' ? renderPropertyName(parameter.name) : parameter.name const optional = parameter.initializer === undefined && parameter.optional && !parameter.rest ? '?' : '' const initializer = parameter.initializer === undefined ? '' : ` = ${parameter.initializer}` - return `${parameter.rest ? '...' : ''}${name}${optional}: ${this.renderType(parameter.type)}${initializer}` + return `${parameter.rest ? '...' : ''}${name}${optional}: ${this.renderType(parameter.type, references)}${initializer}` } - private renderTypeParameters(parameters: readonly TypeParameterModel[]): string { + private renderTypeParameters(parameters: readonly TypeParameterModel[], references?: ReadonlyMap): string { return parameters.length === 0 ? '' - : `<${parameters.map(parameter => this.renderTypeParameter(parameter, true)).join(', ')}>` + : `<${parameters.map(parameter => this.renderTypeParameter(parameter, true, references)).join(', ')}>` } - private renderTypeParameter(parameter: TypeParameterModel, includeDefault: boolean): string { + private renderTypeParameter( + parameter: TypeParameterModel, + includeDefault: boolean, + references?: ReadonlyMap, + ): string { const variance = parameter.variance === undefined ? '' : `${parameter.variance === 'in-out' ? 'in out' : parameter.variance} ` const constModifier = parameter.const ? 'const ' : '' - const constraint = parameter.constraint === undefined ? '' : ` extends ${this.renderType(parameter.constraint)}` - const fallback = !includeDefault || parameter.default === undefined ? '' : ` = ${this.renderType(parameter.default)}` + const constraint = parameter.constraint === undefined ? '' : ` extends ${this.renderType(parameter.constraint, references)}` + const fallback = !includeDefault || parameter.default === undefined ? '' : ` = ${this.renderType(parameter.default, references)}` return `${constModifier}${variance}${parameter.name}${constraint}${fallback}` } - private renderObject(members: readonly MemberModel[]): string { + private renderObject(members: readonly MemberModel[], references?: ReadonlyMap): string { if (members.length === 0) return '{}' - return `{ ${members.map(member => `${this.renderMember(member)};`).join(' ')} }` + return `{ ${members.map(member => `${this.renderMember(member, false, references)};`).join(' ')} }` } private indexParameters(parameters: readonly TypeParameterModel[]): void { diff --git a/packages/typert/generator/src/tsdown-plugin.ts b/packages/typert/generator/src/tsdown-plugin.ts index 9254eeb16d..a5c6ef93e2 100644 --- a/packages/typert/generator/src/tsdown-plugin.ts +++ b/packages/typert/generator/src/tsdown-plugin.ts @@ -2,7 +2,7 @@ * Optional tsdown (rolldown) plugin face of the typert generator. When added * to a workspace tsdown config, it runs after each opted-in package bundle is * written and re-emits its model-driven face artifact at the package output - * root. Packages without a Typert export are skipped. + * root. Packages without a Typert or Remote export are skipped. * @module @deepseek-ai/dsh-typert-generator/tsdown */ @@ -10,6 +10,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' import { WorkspaceTypertGenerator } from './workspace.ts' import type { WorkspaceEmitResult } from './workspace.ts' +import type { TypertFace } from './model.ts' /** The subset of the rolldown output-plugin contract this plugin uses (structural; avoids a rolldown type dependency). */ interface TypertPlugin { @@ -17,21 +18,37 @@ interface TypertPlugin { writeBundle: (options: { dir?: string }) => void } +/** Generation scope selected by a tsdown build phase. */ +export interface TypertPluginOptions { + /** Package mode emits only the package being bundled; workspace mode emits every explicit contributor once. */ + readonly mode?: 'package' | 'workspace' + /** Independent TypeScript program faces included in this phase. */ + readonly faces?: readonly TypertFace[] +} + /** * Create the typert generation plugin for the root tsdown config. - * @returns a rolldown-compatible plugin that emits `lib/typert..js` and `.d.ts` for contributing packages. + * @param pluginOptions - package/workspace emission mode and independent program faces. + * @returns a rolldown-compatible plugin that emits local face and Host-for-Client Remote artifacts. */ -export function typertPlugin(): TypertPlugin { +export function typertPlugin(pluginOptions: TypertPluginOptions = {}): TypertPlugin { const artifactsByRoot = new Map() + const emittedWorkspaces = new Set() return { name: 'dsh-typert-generator', - writeBundle(options) { + writeBundle(bundleOptions) { // options.dir is the package's absolute outDir (/lib); its // nearest package.json owns the bundle even when a custom config writes // a nested output such as /lib/dev. - if (options.dir === undefined) return - const root = workspaceRoot(options.dir) - const packageDir = packageRoot(options.dir, root) + if (bundleOptions.dir === undefined) return + const root = workspaceRoot(bundleOptions.dir) + if (emittedWorkspaces.has(root)) return + if (pluginOptions.mode === 'workspace') { + emitWorkspace(root, pluginOptions.faces) + emittedWorkspaces.add(root) + return + } + const packageDir = packageRoot(bundleOptions.dir, root) if (packageDir === undefined) return const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as { name?: string @@ -40,22 +57,54 @@ export function typertPlugin(): TypertPlugin { if (manifest.name === undefined || !hasTypertExport(manifest.exports)) return let artifacts = artifactsByRoot.get(root) if (artifacts === undefined) { - artifacts = new WorkspaceTypertGenerator(root).generate() + const generator = new WorkspaceTypertGenerator(root) + artifacts = pluginOptions.faces === undefined + ? generator.generate() + : generator.generate(undefined, pluginOptions.faces) artifactsByRoot.set(root, artifacts) } - const output = join(packageDir, 'lib') - mkdirSync(output, { recursive: true }) - for (const artifact of artifacts.filter(candidate => candidate.package === manifest.name)) { - writeFileSync(join(output, `typert.${artifact.face}.js`), artifact.js) - writeFileSync(join(output, `typert.${artifact.face}.d.ts`), artifact.dts) - } + emitArtifacts(packageDir, artifacts.filter(candidate => candidate.package === manifest.name)) }, } + + function emitWorkspace(root: string, faces: readonly TypertFace[] | undefined): void { + const generator = new WorkspaceTypertGenerator(root) + const packages = generator.discover(faces) + .filter(candidate => hasTypertExport(readManifest(join(root, candidate.root)).exports)) + .map(candidate => candidate.package) + if (packages.length === 0) return + for (const artifact of generator.generate(packages, faces)) { + emitArtifacts(join(root, artifact.packageRoot), [artifact]) + } + } +} + +function emitArtifacts(packageDir: string, artifacts: readonly WorkspaceEmitResult[]): void { + const output = join(packageDir, 'lib') + mkdirSync(output, { recursive: true }) + for (const artifact of artifacts) { + writeFileSync(join(output, `typert.${artifact.face}.js`), artifact.js) + writeFileSync(join(output, `typert.${artifact.face}.d.ts`), artifact.dts) + if (artifact.remote !== undefined) { + writeFileSync(join(output, 'typert.remote-client.js'), artifact.remote.js) + writeFileSync(join(output, 'typert.remote-client.d.ts'), artifact.remote.dts) + writeFileSync(join(output, 'typert.remote-client.d.ts.map'), artifact.remote.dtsMap) + } + } +} + +function readManifest(packageDir: string): { name?: string; exports?: unknown } { + return JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as { + name?: string + exports?: unknown + } } function hasTypertExport(exportsField: unknown): boolean { if (exportsField === null || typeof exportsField !== 'object' || Array.isArray(exportsField)) return false - return Object.hasOwn(exportsField, './typert') || Object.hasOwn(exportsField, './client/typert') + return Object.hasOwn(exportsField, './typert') + || Object.hasOwn(exportsField, './client/typert') + || Object.hasOwn(exportsField, './remote') } function packageRoot(start: string, workspace: string): string | undefined { diff --git a/packages/typert/generator/src/workspace.ts b/packages/typert/generator/src/workspace.ts index 6153a0241a..c79861a796 100644 --- a/packages/typert/generator/src/workspace.ts +++ b/packages/typert/generator/src/workspace.ts @@ -9,6 +9,7 @@ import { TypertAnalysisError, WorkspaceAnalyzer } from './analyzer.ts' import type { DiscoveredTypertPackage } from './analyzer.ts' import { FaceModelEmitter } from './emitter.ts' import type { ModelEmitResult } from './emitter.ts' +import type { TypertFace } from './model.ts' /** One emitted artifact paired with its source package root. */ export interface WorkspaceEmitResult extends ModelEmitResult { @@ -26,20 +27,29 @@ export class WorkspaceTypertGenerator { /** * Find public package faces that contribute Cordis services/events or * explicitly tagged Typert roots. + * @param faces - optional independent program faces to inspect. * @returns discovered packages in stable package-name order. */ - discover(): DiscoveredTypertPackage[] { - return new WorkspaceAnalyzer({ root: this.root }).discoverPackages() + discover(faces?: readonly TypertFace[]): DiscoveredTypertPackage[] { + return new WorkspaceAnalyzer({ + root: this.root, + ...(faces === undefined ? {} : { faces }), + }).discoverPackages() } /** * Generate all discovered contributors, or an explicit package subset. * @param packages - optional exact package names for a focused pass. + * @param faces - optional independent program faces to analyze. * @returns one artifact per package face. */ - generate(packages?: readonly string[]): WorkspaceEmitResult[] { - const selected = packages ?? this.discover().map(candidate => candidate.package) - const workspace = new WorkspaceAnalyzer({ root: this.root, packages: selected }).analyze() + generate(packages?: readonly string[], faces?: readonly TypertFace[]): WorkspaceEmitResult[] { + const selected = packages ?? this.discover(faces).map(candidate => candidate.package) + const workspace = new WorkspaceAnalyzer({ + root: this.root, + packages: selected, + ...(faces === undefined ? {} : { faces }), + }).analyze() const artifacts: WorkspaceEmitResult[] = [] for (const face of workspace.faces) { const emitter = new FaceModelEmitter(face) @@ -80,6 +90,28 @@ export class WorkspaceTypertGenerator { throw new TypertAnalysisError(`typert(${artifact.face}): ${artifact.package} package files must include ${file}`) } } + if (artifact.remote === undefined) return + const remoteExpected = { + types: './lib/typert.remote-client.d.ts', + default: './lib/typert.remote-client.js', + } + const remoteActual = manifest.exports !== null && typeof manifest.exports === 'object' + ? (manifest.exports as Record)['./remote'] + : undefined + if (!sameExport(remoteActual, remoteExpected)) { + throw new TypertAnalysisError( + `typert(host): ${artifact.package} must export ./remote as ${JSON.stringify(remoteExpected)}`, + ) + } + for (const file of [ + 'lib/typert.remote-client.js', + 'lib/typert.remote-client.d.ts', + 'lib/typert.remote-client.d.ts.map', + ]) { + if (!files.includes(file)) { + throw new TypertAnalysisError(`typert(host): ${artifact.package} package files must include ${file}`) + } + } } } diff --git a/packages/typert/generator/tests/__snapshots__/type-model.spec.ts.snap b/packages/typert/generator/tests/__snapshots__/type-model.spec.ts.snap index aad86b0102..bcc28cd8b2 100644 --- a/packages/typert/generator/tests/__snapshots__/type-model.spec.ts.snap +++ b/packages/typert/generator/tests/__snapshots__/type-model.spec.ts.snap @@ -17,6 +17,8 @@ export const TYPERT = { schemas: [ { name: 'Payload', schema: Payload }, ], + invocations: [ + ], model: { "services": [ { @@ -3815,6 +3817,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "abstract": false, "async": false, "id": "type:packages/host/src/models.ts:123:11#1#['computed']@3756", + "jsonName": "computed", "kind": "property", "location": { "column": 5, @@ -5634,6 +5637,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "symbol": "@fixture/host:packages/host/src/models.ts#Variance", }, ], + "invocations": [], "name": "@fixture/host", "objects": [ { @@ -6449,6 +6453,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "symbol": ":../../../../../../node_modules/.pnpm/zod@4.4.3/node_modules/zod/v4/classic/schemas.d.cts#ZodType", }, ], + "invocations": [], "name": "@fixture/client", "objects": [], "root": "packages/client", diff --git a/packages/typert/generator/tests/fixtures/remote-model/package.json b/packages/typert/generator/tests/fixtures/remote-model/package.json new file mode 100644 index 0000000000..00ac86bdcc --- /dev/null +++ b/packages/typert/generator/tests/fixtures/remote-model/package.json @@ -0,0 +1,5 @@ +{ + "name": "@fixture/remote-workspace", + "private": true, + "type": "module" +} diff --git a/packages/typert/generator/tests/fixtures/remote-model/packages/domain/package.json b/packages/typert/generator/tests/fixtures/remote-model/packages/domain/package.json new file mode 100644 index 0000000000..bf6b2bd110 --- /dev/null +++ b/packages/typert/generator/tests/fixtures/remote-model/packages/domain/package.json @@ -0,0 +1,9 @@ +{ + "name": "@fixture/domain", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./types": "./src/types.ts" + } +} diff --git a/packages/typert/generator/tests/fixtures/remote-model/packages/domain/src/index.ts b/packages/typert/generator/tests/fixtures/remote-model/packages/domain/src/index.ts new file mode 100644 index 0000000000..e5c2850cf2 --- /dev/null +++ b/packages/typert/generator/tests/fixtures/remote-model/packages/domain/src/index.ts @@ -0,0 +1,19 @@ +import type { TypeRTContext, TypeRTLookup } from '@deepseek-ai/dsh-type-meta' +import type { AgentId } from './types.ts' + +/** Host-only live Agent object. */ +export class Agent { + constructor(readonly id: AgentId) {} +} + +declare module '@deepseek-ai/dsh-type-meta' { + interface TypeRTLookupMap { + agent: TypeRTLookup + } + + interface TypeRTContextMap { + agent: TypeRTContext + } +} + +export type { AgentId } from './types.ts' diff --git a/packages/typert/generator/tests/fixtures/remote-model/packages/domain/src/types.ts b/packages/typert/generator/tests/fixtures/remote-model/packages/domain/src/types.ts new file mode 100644 index 0000000000..944201e82a --- /dev/null +++ b/packages/typert/generator/tests/fixtures/remote-model/packages/domain/src/types.ts @@ -0,0 +1,2 @@ +/** Stable Agent identity crossing the Remote boundary. */ +export type AgentId = string diff --git a/packages/typert/generator/tests/fixtures/remote-model/packages/domain/tsconfig.json b/packages/typert/generator/tests/fixtures/remote-model/packages/domain/tsconfig.json new file mode 100644 index 0000000000..1ddc9b1a60 --- /dev/null +++ b/packages/typert/generator/tests/fixtures/remote-model/packages/domain/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true + }, + "include": ["src"] +} diff --git a/packages/typert/generator/tests/fixtures/remote-model/packages/remote/package.json b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/package.json new file mode 100644 index 0000000000..b7e0631a0a --- /dev/null +++ b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/package.json @@ -0,0 +1,24 @@ +{ + "name": "@fixture/remote", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./types": "./src/types.ts", + "./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" + } + }, + "files": [ + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts", + "lib/typert.remote-client.d.ts.map" + ] +} diff --git a/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts new file mode 100644 index 0000000000..816a13a5a7 --- /dev/null +++ b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts @@ -0,0 +1,30 @@ +import { Remote, RemoteContext, bindTypeRTGateway } from '@deepseek-ai/dsh-type-meta' +import type { Agent } from '@fixture/domain' +import type { + CreateGoalRequest, + CreateGoalResult, + RenameGoalRequest, + RenameGoalResult, +} from './types.ts' + +/** Remote-only business Service with no Cordis declaration merge. */ +export class GoalService { + readonly typertGateway = bindTypeRTGateway(this, 'goals') + + @Remote + async create(agent: Agent, request: CreateGoalRequest): Promise { + return { ref: `${agent.id}:${request.title}` } + } + + @RemoteContext('agent') + rename(request: RenameGoalRequest): RenameGoalResult { + return { renamed: request.title.length > 0 } + } +} + +export type { + CreateGoalRequest, + CreateGoalResult, + RenameGoalRequest, + RenameGoalResult, +} from './types.ts' diff --git a/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/types.ts b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/types.ts new file mode 100644 index 0000000000..88493325f8 --- /dev/null +++ b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/types.ts @@ -0,0 +1,20 @@ +/** Input accepted by Goal creation. */ +export interface CreateGoalRequest { + readonly title: string +} + +/** Wire-safe Goal creation result. */ +export interface CreateGoalResult { + readonly ref: string +} + +/** Input accepted by scoped Goal renaming. */ +export interface RenameGoalRequest { + readonly ref: string + readonly title: string +} + +/** Wire-safe Goal rename result. */ +export interface RenameGoalResult { + readonly renamed: boolean +} diff --git a/packages/typert/generator/tests/fixtures/remote-model/packages/remote/tsconfig.json b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/tsconfig.json new file mode 100644 index 0000000000..534b3c3d75 --- /dev/null +++ b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true + }, + "include": ["src"], + "references": [ + { "path": "../domain" } + ] +} diff --git a/packages/typert/generator/tests/fixtures/remote-model/tsconfig.base.json b/packages/typert/generator/tests/fixtures/remote-model/tsconfig.base.json new file mode 100644 index 0000000000..4aaf57160d --- /dev/null +++ b/packages/typert/generator/tests/fixtures/remote-model/tsconfig.base.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2024", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "composite": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "ignoreDeprecations": "6.0", + "paths": { + "@deepseek-ai/dsh-type-meta": ["./type-meta.d.ts"], + "@fixture/domain": ["./packages/domain/src/index.ts"], + "@fixture/domain/*": ["./packages/domain/src/*"], + "@fixture/remote": ["./packages/remote/src/index.ts"], + "@fixture/remote/*": ["./packages/remote/src/*"] + }, + "skipLibCheck": true + } +} diff --git a/packages/typert/generator/tests/fixtures/remote-model/tsconfig.host.json b/packages/typert/generator/tests/fixtures/remote-model/tsconfig.host.json new file mode 100644 index 0000000000..7797b7ff29 --- /dev/null +++ b/packages/typert/generator/tests/fixtures/remote-model/tsconfig.host.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.base.json", + "files": [], + "references": [ + { "path": "./packages/domain" }, + { "path": "./packages/remote" } + ] +} diff --git a/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts b/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts new file mode 100644 index 0000000000..f8e84bbe90 --- /dev/null +++ b/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts @@ -0,0 +1,45 @@ +declare module '@deepseek-ai/dsh-type-meta' { + export interface TypeRTLookup { + readonly host: Host + readonly wire: Wire + } + + export interface TypeRTContext { + readonly wire: Wire + } + + export interface TypeRTLookupMap {} + export interface TypeRTContextMap {} + export interface TypeRTRemoteMap {} + export interface TypeRTRemoteContextMap {} + + export type TypeRTRemoteNamespace = { + [Endpoint in keyof TypeRTRemoteMap as Endpoint extends `${Namespace}/${infer Method}` + ? Method + : never]: TypeRTRemoteMap[Endpoint] + } + + export interface TypeRTRemoteNamespaceMap {} + + export interface TypeRTRemoteContribution { + readonly package: string + readonly descriptors: readonly unknown[] + } + + export function bindTypeRTGateway( + service: Service, + serviceKey: string, + options?: { readonly namespace?: string }, + ): { readonly service: Service; readonly serviceKey: string; readonly namespace: string } + + export function Remote( + method: (this: This, ...args: Args) => Result, + context: ClassMethodDecoratorContext Result>, + ): void + + export function RemoteContext(key: Extract): + ( + method: (this: This, ...args: Args) => Result, + context: ClassMethodDecoratorContext Result>, + ) => void +} diff --git a/packages/typert/generator/tests/remote-model.spec.ts b/packages/typert/generator/tests/remote-model.spec.ts new file mode 100644 index 0000000000..90056e673e --- /dev/null +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -0,0 +1,486 @@ +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import ts from 'typescript' +import { afterEach, describe, expect, it } from 'vitest' +import { WorkspaceAnalyzer } from '../src/analyzer.ts' +import type { InvocationModel } from '../src/model.ts' +import { WorkspaceTypertGenerator } from '../src/workspace.ts' + +const fixtureRoot = resolve(import.meta.dirname, 'fixtures/remote-model') +const temporaryRoots: string[] = [] + +interface RuntimeSchema { + safeParse(value: unknown): { readonly success: boolean } +} + +interface RuntimeDescriptor { + readonly id: string + readonly parameters: readonly { + readonly wire: string + readonly codec: { readonly schema: RuntimeSchema } + }[] + readonly result: { readonly schema: RuntimeSchema } +} + +interface RuntimeRemoteModule { + readonly TYPERT_REMOTE: { + readonly package: string + readonly descriptors: readonly RuntimeDescriptor[] + } +} + +interface RemoteDeclarationMap { + readonly file: string + readonly names: readonly string[] + readonly sources: readonly string[] +} + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +describe('Remote model generation', { timeout: 60_000 }, () => { + it('discovers a Remote-only package and emits strict direct and Context descriptors', async () => { + const generator = new WorkspaceTypertGenerator(fixtureRoot) + + expect(generator.discover()).toEqual([{ + package: '@fixture/remote', + root: 'packages/remote', + faces: ['host'], + }]) + + const [artifact] = generator.generate() + expect(artifact).toBeDefined() + expect(artifact).toMatchObject({ + package: '@fixture/remote', + face: 'host', + packageRoot: 'packages/remote', + }) + + const model = remotePackage(fixtureRoot) + expect(model.services).toEqual([]) + expect(model.invocations).toHaveLength(2) + expect(model.invocations[0]).toMatchObject({ + id: '@fixture/remote#goals/create', + service: 'goals', + namespace: 'goals', + method: 'create', + invocation: { kind: 'direct' }, + scope: { context: 'agent', wire: 'agentId' }, + parameters: [ + { + name: 'agent', + wire: 'agentId', + source: 'lookup', + lookup: 'agent', + boundary: { typeSymbol: '@fixture/domain/types#AgentId' }, + }, + { + name: 'request', + wire: 'request', + source: 'json', + boundary: { typeSymbol: '@fixture/remote/types#CreateGoalRequest' }, + }, + ], + result: { typeSymbol: '@fixture/remote/types#CreateGoalResult' }, + }) + expect(model.invocations[1]).toMatchObject({ + id: '@fixture/remote#goals/rename', + service: 'goals', + namespace: 'goals', + method: 'rename', + invocation: { + kind: 'context', + context: 'agent', + wire: 'agentId', + boundary: { typeSymbol: '@fixture/domain/types#AgentId' }, + }, + parameters: [{ + name: 'request', + wire: 'request', + source: 'json', + boundary: { typeSymbol: '@fixture/remote/types#RenameGoalRequest' }, + }], + result: { typeSymbol: '@fixture/remote/types#RenameGoalResult' }, + }) + + expect(artifact?.js).toContain('invocations: [') + expect(artifact?.remote?.dts).toContain( + "'goals/create': (agentId: AgentId, request: CreateGoalRequest) => Promise", + ) + expect(artifact?.remote?.dts).toContain('interface TypeRTRemoteNamespace$676f616c73 {\n create:') + expect(artifact?.remote?.dts).toContain("'goals': TypeRTRemoteNamespace$676f616c73") + expect(artifact?.remote?.dts).toContain( + "'agent:goals/create': (request: CreateGoalRequest) => Promise", + ) + expect(artifact?.remote?.dts).toContain( + "'agent:goals/rename': (request: RenameGoalRequest) => Promise", + ) + + const remoteJs = artifact?.remote?.js + if (remoteJs === undefined) throw new Error('Remote fixture emitted no Host-for-Client JavaScript') + const executable = remoteJs.replace("from 'zod'", `from ${JSON.stringify(import.meta.resolve('zod'))}`) + const generated = await import(`data:text/javascript,${encodeURIComponent(executable)}`) as RuntimeRemoteModule + expect(generated.TYPERT_REMOTE.package).toBe('@fixture/remote') + const create = generated.TYPERT_REMOTE.descriptors[0] + expect(create?.parameters[1]?.codec.schema.safeParse({ title: 'ship' }).success).toBe(true) + expect(create?.parameters[1]?.codec.schema.safeParse({ title: 1 }).success).toBe(false) + expect(create?.result.schema.safeParse({ ref: 'goal-1' }).success).toBe(true) + expect(create?.result.schema.safeParse({ ref: 1 }).success).toBe(false) + + const declarationMap = JSON.parse(artifact?.remote?.dtsMap ?? '') as RemoteDeclarationMap + expect(declarationMap).toMatchObject({ + file: 'typert.remote-client.d.ts', + sources: ['../src/index.ts'], + }) + expect(declarationMap.names).toContain('create') + + assertRemoteConsumerTypechecks(artifact?.remote?.dts, artifact?.remote?.dtsMap) + }) + + it('evaluates declaration-merged mapped and conditional boundaries for codecs without widening consumer types', async () => { + const root = copyFixture() + editFile(root, 'packages/remote/src/types.ts', source => `${source} + +/** Recursive JSON fixture used by the concrete codec projection. */ +export type Json = null | boolean | number | string | Json[] | { [key: string]: Json } + +/** Merge-extensible operation table represented by concrete fixture entries. */ +export interface GenericRemoteMap { + ship: { + readonly request: { readonly count: number; readonly meta: Json } + readonly result: { readonly accepted: boolean } + } + cancel: { + readonly request: { readonly reason: string } + readonly result: { readonly cancelled: boolean } + } +} + +type GenericRemoteKey = Extract +type RequestOf = GenericRemoteMap[K] extends { readonly request: infer Request } + ? Request + : never +type ResultOf = GenericRemoteMap[K] extends { readonly result: infer Result } + ? Result + : never + +/** Strict request union retained in the generated Client declaration. */ +export type GenericRequest = { + [K in GenericRemoteKey]: { readonly kind: K; readonly payload: RequestOf } +}[GenericRemoteKey] + +/** Strict result union retained in the generated Client declaration. */ +export type GenericResult = { + [K in GenericRemoteKey]: { readonly kind: K; readonly value: ResultOf } +}[GenericRemoteKey] +`) + editFile(root, 'packages/remote/src/index.ts', source => source + .replace( + ' RenameGoalResult,\n', + ' RenameGoalResult,\n GenericRequest,\n GenericResult,\n', + ) + .replace( + ' rename(request: RenameGoalRequest): RenameGoalResult {\n return { renamed: request.title.length > 0 }\n }\n}', + ` rename(request: RenameGoalRequest): RenameGoalResult { + return { renamed: request.title.length > 0 } + } + + @Remote + dispatch(request: GenericRequest): GenericResult { + if (request.kind === 'ship') return { kind: 'ship', value: { accepted: request.payload.count > 0 } } + return { kind: 'cancel', value: { cancelled: request.payload.reason.length > 0 } } + } +}`, + )) + + const [artifact] = new WorkspaceTypertGenerator(root).generate() + expect(artifact?.remote?.dts).toContain( + "'goals/dispatch': (request: GenericRequest) => Promise", + ) + const remoteJs = artifact?.remote?.js + if (remoteJs === undefined) throw new Error('generic Remote fixture emitted no Host-for-Client JavaScript') + const executable = remoteJs.replace("from 'zod'", `from ${JSON.stringify(import.meta.resolve('zod'))}`) + const generated = await import(`data:text/javascript,${encodeURIComponent(executable)}`) as RuntimeRemoteModule + const dispatch = generated.TYPERT_REMOTE.descriptors.find(descriptor => descriptor.id.endsWith('/dispatch')) + const schema = dispatch?.parameters[0]?.codec.schema + expect(schema?.safeParse({ kind: 'ship', payload: { count: 2, meta: { nested: [true, null] } } }).success).toBe(true) + expect(schema?.safeParse({ kind: 'ship', payload: { count: '2', meta: {} } }).success).toBe(false) + expect(schema?.safeParse({ kind: 'cancel', payload: { reason: 'obsolete' } }).success).toBe(true) + expect(schema?.safeParse({ kind: 'unknown', payload: {} }).success).toBe(false) + expect(dispatch?.result.schema.safeParse({ kind: 'ship', value: { accepted: true } }).success).toBe(true) + expect(dispatch?.result.schema.safeParse({ kind: 'ship', value: { cancelled: true } }).success).toBe(false) + }) + + it.each([ + { + name: 'missing binding', + edit: (source: string) => source.replace(" readonly typertGateway = bindTypeRTGateway(this, 'goals')\n\n", ''), + message: 'Remote methods require readonly typertGateway', + }, + { + name: 'private method', + edit: (source: string) => source.replace(' async create(', ' private async create('), + message: 'Remote decorators require a public instance method', + }, + { + name: 'static method', + edit: (source: string) => source.replace(' async create(', ' static async create('), + message: 'Remote decorators require a public instance method', + }, + { + name: 'abstract method', + edit: (source: string) => source + .replace('export class GoalService', 'export abstract class GoalService') + .replace( + ' async create(agent: Agent, request: CreateGoalRequest): Promise {\n return { ref: `${agent.id}:${request.title}` }\n }', + ' abstract create(agent: Agent, request: CreateGoalRequest): Promise', + ), + message: 'Remote methods must have a concrete implementation', + }, + { + name: 'generic method', + edit: (source: string) => source.replace(' async create(', ' async create('), + message: 'generic Remote methods are not supported', + }, + { + name: 'destructured parameter', + edit: (source: string) => source.replace('request: CreateGoalRequest', '{ title }: CreateGoalRequest'), + message: 'Remote parameters must use identifier bindings', + }, + { + name: 'rest parameter', + edit: (source: string) => source.replace('request: CreateGoalRequest', '...request: [CreateGoalRequest]'), + message: 'Remote parameters cannot be rest parameters', + }, + { + name: 'default parameter', + edit: (source: string) => source.replace( + 'request: CreateGoalRequest', + "request: CreateGoalRequest = { title: '' }", + ), + message: 'Remote parameters cannot have default values', + }, + { + name: 'optional parameter', + edit: (source: string) => source.replace('request: CreateGoalRequest', 'request?: CreateGoalRequest'), + message: 'Remote parameters cannot be optional', + }, + ])('rejects $name', ({ edit, message }) => { + const root = copyFixture() + editFile(root, 'packages/remote/src/index.ts', edit) + + expect(() => analyzeRemote(root, false)).toThrow(new RegExp(message)) + }) + + it('rejects a workspace class parameter without a lookup declaration', () => { + const root = copyFixture() + editFile(root, 'packages/domain/src/index.ts', source => source.replace( + ' interface TypeRTLookupMap {\n agent: TypeRTLookup\n }\n\n', + '', + )) + + expect(() => analyzeRemote(root, false)).toThrow(/non-JSON class parameter Agent requires a TypeRTLookupMap entry/) + }) + + it('rejects a Remote Context without a static Context declaration', () => { + const root = copyFixture() + editFile(root, 'packages/remote/src/index.ts', source => source.replace("@RemoteContext('agent')", "@RemoteContext('missing')")) + + expect(() => analyzeRemote(root, false)).toThrow(/Remote Context missing has no TypeRTContextMap entry/) + }) + + it('rejects a direct scoped projection whose Context and lookup wire symbols differ', () => { + const root = copyFixture() + editFile(root, 'packages/domain/src/types.ts', source => `${source}\n/** Deliberately distinct Context identity for the failure fixture. */\nexport type OtherAgentId = string\n`) + editFile(root, 'packages/domain/src/index.ts', source => source + .replace("import type { AgentId } from './types.ts'", "import type { AgentId, OtherAgentId } from './types.ts'") + .replace('agent: TypeRTContext', 'agent: TypeRTContext')) + + expect(() => analyzeRemote(root, false)).toThrow(/Remote scope agent wire type .* does not match lookup wire type/) + }) + + it('rejects duplicate endpoints across Remote services', () => { + const root = copyFixture() + editFile(root, 'packages/remote/src/index.ts', source => `${source} +export class DuplicateGoalService { + readonly typertGateway = bindTypeRTGateway(this, 'duplicate', { namespace: 'goals' }) + + @Remote + create(request: CreateGoalRequest): CreateGoalResult { + return { ref: request.title } + } +} +`) + + expect(() => analyzeRemote(root, false)).toThrow(/Remote endpoint goals\/create conflicts/) + }) +}) + +function analyzeRemote(root: string, checkDiagnostics = true): ReturnType { + return new WorkspaceAnalyzer({ root, checkDiagnostics }).analyze() +} + +function remotePackage(root: string): { + readonly services: readonly unknown[] + readonly invocations: readonly InvocationModel[] +} { + const host = analyzeRemote(root).faces.find(face => face.face === 'host') + const packageModel = host?.packages.find(candidate => candidate.name === '@fixture/remote') + if (packageModel === undefined) throw new Error('Remote fixture package was not modeled on the host face') + return packageModel +} + +function copyFixture(): string { + const root = mkdtempSync(join(tmpdir(), 'dsh-typert-remote-model-')) + cpSync(fixtureRoot, root, { recursive: true }) + temporaryRoots.push(root) + return root +} + +function editFile(root: string, relativePath: string, edit: (source: string) => string): void { + const path = join(root, relativePath) + const source = readFileSync(path, 'utf8') + const result = edit(source) + if (result === source) throw new Error(`fixture edit made no change to ${relativePath}`) + writeFileSync(path, result) +} + +function assertRemoteConsumerTypechecks(dts: string | undefined, dtsMap: string | undefined): void { + if (dts === undefined) throw new Error('Remote fixture emitted no Host-for-Client declaration') + if (dtsMap === undefined) throw new Error('Remote fixture emitted no Host-for-Client declaration map') + const consumerRoot = copyFixture() + const declarationPath = join(consumerRoot, 'packages/remote/lib/typert.remote-client.d.ts') + const declarationMapPath = `${declarationPath}.map` + const consumerPath = join(consumerRoot, 'consumer.ts') + mkdirSync(join(consumerRoot, 'packages/remote/lib'), { recursive: true }) + writeFileSync(declarationPath, dts, { flush: true }) + writeFileSync(declarationMapPath, dtsMap, { flush: true }) + assertRemoteConsumerWithoutImportHasNoNamespace(consumerRoot) + const consumerSource = ` +import remote from '@fixture/remote/remote' +import type { + TypeRTRemoteContribution, + TypeRTRemoteContextMap, + TypeRTRemoteMap, + TypeRTRemoteNamespaceMap, +} from '@deepseek-ai/dsh-type-meta' +import type { CreateGoalResult, RenameGoalResult } from '@fixture/remote/types' + +const contribution: TypeRTRemoteContribution = remote +declare const create: TypeRTRemoteMap['goals/create'] +declare const createScoped: TypeRTRemoteContextMap['agent:goals/create'] +declare const rename: TypeRTRemoteContextMap['agent:goals/rename'] +const created: Promise = create('agent-1', { title: 'ship' }) +const createdScoped: Promise = createScoped({ title: 'ship' }) +const renamed: Promise = rename({ ref: 'goal-1', title: 'land' }) +declare const ctx: { api: TypeRTRemoteNamespaceMap } +const navigated: Promise = ctx.api.goals.create('agent-1', { title: 'navigate' }) +void contribution +void created +void createdScoped +void renamed +void navigated +` + writeFileSync(consumerPath, consumerSource) + const configPath = join(consumerRoot, 'tsconfig.consumer.json') + writeFileSync(configPath, JSON.stringify({ + extends: './tsconfig.base.json', + compilerOptions: { + composite: false, + skipLibCheck: false, + paths: { + '@deepseek-ai/dsh-type-meta': ['./type-meta.d.ts'], + '@fixture/domain/types': ['./packages/domain/src/types.ts'], + '@fixture/remote/types': ['./packages/remote/src/types.ts'], + '@fixture/remote/remote': ['./packages/remote/lib/typert.remote-client.d.ts'], + }, + }, + files: ['./consumer.ts'], + }, null, 2)) + const config = ts.readConfigFile(configPath, file => ts.sys.readFile(file)) + if (config.error !== undefined) throw new Error(formatDiagnostics([config.error])) + const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, consumerRoot, undefined, configPath) + const program = ts.createProgram(parsed.fileNames, parsed.options) + const diagnostics = ts.getPreEmitDiagnostics(program) + expect(diagnostics, formatDiagnostics(diagnostics)).toEqual([]) + + const languageService = ts.createLanguageService({ + getCompilationSettings: () => parsed.options, + getCurrentDirectory: () => consumerRoot, + getDefaultLibFileName: options => ts.getDefaultLibFilePath(options), + getScriptFileNames: () => parsed.fileNames, + getScriptSnapshot: (fileName) => { + const source = ts.sys.readFile(fileName) + return source === undefined ? undefined : ts.ScriptSnapshot.fromString(source) + }, + getScriptVersion: () => '0', + directoryExists: path => ts.sys.directoryExists(path), + fileExists: path => ts.sys.fileExists(path), + getDirectories: path => ts.sys.getDirectories(path), + readDirectory: (path, extensions, exclude, include, depth) => + ts.sys.readDirectory(path, extensions, exclude, include, depth), + readFile: path => ts.sys.readFile(path), + realpath: path => ts.sys.realpath?.(path) ?? path, + }) + const navigation = 'ctx.api.goals.create' + const position = consumerSource.indexOf(navigation) + navigation.lastIndexOf('create') + 1 + const definitions = languageService.getDefinitionAtPosition(consumerPath, position) + const generatedDefinition = definitions?.find(candidate => candidate.fileName === declarationPath) + if (generatedDefinition === undefined) { + throw new Error(`generated Remote definition not found: ${JSON.stringify(definitions, null, 2)}`) + } + const sourceMapper = (languageService as unknown as { + getSourceMapper(): { + tryGetSourcePosition(location: { readonly fileName: string; readonly pos: number }): + { readonly fileName: string; readonly pos: number } | undefined + } + }).getSourceMapper() + const definition = sourceMapper.tryGetSourcePosition({ + fileName: generatedDefinition.fileName, + pos: generatedDefinition.textSpan.start, + }) + languageService.dispose() + if (definition === undefined || !definition.fileName.endsWith('/packages/remote/src/index.ts')) { + throw new Error(`generated Remote definition did not map to its Host source: ${JSON.stringify(definition)}`) + } + const hostSource = readFileSync(join(consumerRoot, 'packages/remote/src/index.ts'), 'utf8') + expect(hostSource.slice(definition.pos, definition.pos + generatedDefinition.textSpan.length)).toBe('create') +} + +function assertRemoteConsumerWithoutImportHasNoNamespace(consumerRoot: string): void { + const consumerPath = join(consumerRoot, 'consumer-without-remote.ts') + writeFileSync(consumerPath, ` +import type { TypeRTRemoteNamespaceMap } from '@deepseek-ai/dsh-type-meta' +declare const ctx: { api: TypeRTRemoteNamespaceMap } +ctx.api.goals.create('agent-1', { title: 'must not compile' }) +`) + const configPath = join(consumerRoot, 'tsconfig.consumer-without-remote.json') + writeFileSync(configPath, JSON.stringify({ + extends: './tsconfig.base.json', + compilerOptions: { + composite: false, + skipLibCheck: false, + paths: { + '@deepseek-ai/dsh-type-meta': ['./type-meta.d.ts'], + }, + }, + files: ['./consumer-without-remote.ts'], + }, null, 2)) + const config = ts.readConfigFile(configPath, file => ts.sys.readFile(file)) + if (config.error !== undefined) throw new Error(formatDiagnostics([config.error])) + const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, consumerRoot, undefined, configPath) + const diagnostics = ts.getPreEmitDiagnostics(ts.createProgram(parsed.fileNames, parsed.options)) + expect(diagnostics).toHaveLength(1) + expect(diagnostics[0]?.code).toBe(2339) + expect(ts.flattenDiagnosticMessageText(diagnostics[0]?.messageText ?? '', '\n')).toContain("Property 'goals' does not exist") +} + +function formatDiagnostics(diagnostics: readonly ts.Diagnostic[]): string { + return ts.formatDiagnosticsWithColorAndContext(diagnostics, { + getCanonicalFileName: file => file, + getCurrentDirectory: () => process.cwd(), + getNewLine: () => '\n', + }) +} diff --git a/packages/typert/generator/tests/schema-emitter.spec.ts b/packages/typert/generator/tests/schema-emitter.spec.ts index 7457c4b85f..16ac97b7ca 100644 --- a/packages/typert/generator/tests/schema-emitter.spec.ts +++ b/packages/typert/generator/tests/schema-emitter.spec.ts @@ -7,6 +7,7 @@ import type { FaceModel, KeywordTypeName, MemberModel, + SignatureMemberModel, SignatureModel, TypeDeclarationModel, TypeNodeModel, @@ -356,6 +357,149 @@ describe('SchemaEmitter supported projection matrix', () => { expect(inheritedSchema.safeParse({ current: 1 }).success).toBe(false) }) + it('instantiates generic aliases, nested references, defaults, and recursive declarations', async () => { + const box = declaration('Box', 'interface', { + typeParameters: [{ id: 'box:value', name: 'Value', const: false }], + members: [property('value', 'box:value-reference')], + }) + const wrapper = declaration('Wrapper', 'alias', { + typeParameters: [ + { id: 'wrapper:value', name: 'Value', const: false }, + { id: 'wrapper:items', name: 'Items', const: false, default: 'wrapper:default-items' }, + ], + type: 'wrapper:box-reference', + }) + const recursive = declaration('Recursive', 'interface', { + typeParameters: [{ id: 'recursive:value', name: 'Value', const: false }], + members: [ + property('value', 'recursive:value-reference'), + property('next', 'recursive:self-reference', { optional: true }), + ], + }) + const schema = await loadSchema(emit([ + { + id: 'root', + kind: 'object', + members: [ + property('wrapped', 'root:wrapper-reference'), + property('recursive', 'root:recursive-reference'), + ], + }, + { + id: 'root:wrapper-reference', + kind: 'reference', + name: 'Wrapper', + target: { kind: 'declaration', symbol: 'Wrapper' }, + arguments: ['string'], + }, + { + id: 'root:recursive-reference', + kind: 'reference', + name: 'Recursive', + target: { kind: 'declaration', symbol: 'Recursive' }, + arguments: ['number'], + }, + { + id: 'wrapper:box-reference', + kind: 'reference', + name: 'Box', + target: { kind: 'declaration', symbol: 'Box' }, + arguments: ['wrapper:items-reference'], + }, + { + id: 'wrapper:default-items', + kind: 'reference', + name: 'ReadonlyArray', + target: { kind: 'standard', name: 'ReadonlyArray' }, + arguments: ['wrapper:value-reference'], + }, + { + id: 'wrapper:value-reference', + kind: 'reference', + name: 'Value', + target: { kind: 'type-parameter', parameter: 'wrapper:value' }, + arguments: [], + }, + { + id: 'wrapper:items-reference', + kind: 'reference', + name: 'Items', + target: { kind: 'type-parameter', parameter: 'wrapper:items' }, + arguments: [], + }, + { + id: 'box:value-reference', + kind: 'reference', + name: 'Value', + target: { kind: 'type-parameter', parameter: 'box:value' }, + arguments: [], + }, + { + id: 'recursive:value-reference', + kind: 'reference', + name: 'Value', + target: { kind: 'type-parameter', parameter: 'recursive:value' }, + arguments: [], + }, + { + id: 'recursive:self-reference', + kind: 'reference', + name: 'Recursive', + target: { kind: 'declaration', symbol: 'Recursive' }, + arguments: ['recursive:value-reference'], + }, + keyword('string', 'string'), + keyword('number', 'number'), + ], undefined, [box, wrapper, recursive])) + + expect(schema.safeParse({ + wrapped: { value: ['one', 'two'] }, + recursive: { value: 1, next: { value: 2 } }, + }).success).toBe(true) + expect(schema.safeParse({ + wrapped: { value: [1] }, + recursive: { value: 1 }, + }).success).toBe(false) + expect(schema.safeParse({ + wrapped: { value: ['one'] }, + recursive: { value: 'one' }, + }).success).toBe(false) + }) + + it('erases unique-symbol nominal members without naming a branding utility', async () => { + const nominal = declaration('Nominal', 'alias', { + typeParameters: [{ id: 'nominal:brand', name: 'Brand', const: false }], + type: 'nominal:intersection', + }) + const symbolMember = { + ...property('[TOKEN]', 'nominal:brand-reference', { readonly: true }), + computed: 'symbol', + } as const + const schema = await loadSchema(emit([ + { + id: 'root', + kind: 'reference', + name: 'Nominal', + target: { kind: 'declaration', symbol: 'Nominal' }, + arguments: ['brand'], + }, + { id: 'brand', kind: 'literal', value: 'Fixture', text: "'Fixture'" }, + { id: 'nominal:intersection', kind: 'intersection', types: ['string', 'nominal:marker'] }, + keyword('string', 'string'), + { id: 'nominal:marker', kind: 'object', members: [symbolMember] }, + { + id: 'nominal:brand-reference', + kind: 'reference', + name: 'Brand', + target: { kind: 'type-parameter', parameter: 'nominal:brand' }, + arguments: [], + }, + ], undefined, [nominal])) + + expect(schema.safeParse('fixture-id').success).toBe(true) + expect(schema.safeParse(1).success).toBe(false) + }) + it('classifies every TypeNode kind and executes every supported kind', () => { const expected = Object.entries(ZOD_NODE_SUPPORT) .filter(([, support]) => support === 'supported') @@ -373,7 +517,6 @@ describe('SchemaEmitter unsupported projection matrix', () => { }) it.each([ - ['type-parameter', { kind: 'type-parameter', parameter: 'parameter' }], ['cross-face', { kind: 'cross-face', face: 'client', package: '@fixture/client', subpath: '.', name: 'Value' }], ['external', { kind: 'external', module: 'external', subpath: '.', name: 'Value' }], ] as const)('rejects %s references explicitly', (kind, target) => { @@ -386,7 +529,33 @@ describe('SchemaEmitter unsupported projection matrix', () => { }])).toThrow(`typert Zod emitter: Value: ${kind} reference has no Zod projection`) }) - it('rejects unsupported standard references, generic declarations, and enums', () => { + it('rejects unbound type parameters, incomplete generic applications, and generic schema exports', () => { + expect(() => emit([{ + id: 'root', + kind: 'reference', + name: 'Value', + target: { kind: 'type-parameter', parameter: 'parameter' }, + arguments: [], + }])).toThrow('type parameter has no schema substitution') + + const generic = declaration('Generic', 'interface', { + typeParameters: [{ id: 'parameter', name: 'Value', const: false }], + }) + expect(() => emit([{ + id: 'root', + kind: 'reference', + name: 'Generic', + target: { kind: 'declaration', symbol: 'Generic' }, + arguments: [], + }], undefined, [generic])).toThrow('missing type argument Value') + + const genericRoot = declaration('Root', 'interface', { + typeParameters: [{ id: 'root:parameter', name: 'Value', const: false }], + }) + expect(() => emit([], genericRoot)).toThrow('generic schema exports require a concrete declaration') + }) + + it('rejects unsupported standard references and enums', () => { const intrinsic = { id: 'root', kind: 'keyword', name: 'intrinsic' } as unknown as TypeNodeModel expect(() => emit([intrinsic])) .toThrow('keyword intrinsic has no Zod projection') @@ -399,17 +568,6 @@ describe('SchemaEmitter unsupported projection matrix', () => { arguments: [], }])).toThrow('standard type Promise has no Zod projection') - const generic = declaration('Generic', 'interface', { - typeParameters: [{ id: 'parameter', name: 'Value', const: false }], - }) - expect(() => emit([{ - id: 'root', - kind: 'reference', - name: 'Generic', - target: { kind: 'declaration', symbol: 'Generic' }, - arguments: [], - }], undefined, [generic])).toThrow('generic declarations require a schema-factory projection') - const enumeration = declaration('Enumeration', 'enum', { enumMembers: [{ ...documentation, name: 'Value', initializer: "'value'", location }], }) @@ -481,6 +639,7 @@ describe('SchemaEmitter unsupported projection matrix', () => { }], objects: [], schemas: [], + invocations: [], }], } expect(() => new FaceModelEmitter(eventFace).emit('@fixture/schema')) @@ -513,6 +672,7 @@ describe('SchemaEmitter unsupported projection matrix', () => { }], objects: [], schemas: [], + invocations: [], }], } @@ -555,7 +715,33 @@ describe('SchemaEmitter unsupported projection matrix', () => { expect(artifact.dts).toContain("from '@fixture/schema/secondary'") }) - it.each(['method', 'getter', 'setter', 'call', 'construct', 'index'] as const)( + it('emits JSON index signatures as record schemas', async () => { + const root = declaration('Root', 'interface', { + members: [indexMember('key', 'value')], + }) + const schema = await loadSchema(emit([ + keyword('key', 'string'), + keyword('value', 'number'), + ], root)) + + expect(schema.safeParse({ one: 1, two: 2 }).success).toBe(true) + expect(schema.safeParse({ one: '1' }).success).toBe(false) + }) + + it('rejects more than one JSON index signature', () => { + const root = declaration('Root', 'interface', { + members: [indexMember('key', 'value'), indexMember('other-key', 'other-value')], + }) + + expect(() => emit([ + keyword('key', 'string'), + keyword('value', 'number'), + keyword('other-key', 'string'), + keyword('other-value', 'boolean'), + ], root)).toThrow('object type has more than one JSON index signature') + }) + + it.each(['method', 'getter', 'setter', 'call', 'construct'] as const)( 'rejects %s members on data-schema objects', (kind) => { expect(() => emit([ @@ -608,6 +794,10 @@ function property( } } +function signatureMember(kind: 'index'): SignatureMemberModel +function signatureMember( + kind: Exclude, +): MemberModel function signatureMember(kind: Exclude): MemberModel { return { ...documentation, @@ -626,6 +816,24 @@ function signatureMember(kind: Exclude): Member } } +function indexMember(key: string, value: string): SignatureMemberModel { + return { + ...signatureMember('index'), + signature: { + typeParameters: [], + parameters: [{ + name: 'key', + binding: 'identifier', + type: key, + optional: false, + rest: false, + receiver: false, + }], + returns: value, + }, + } +} + function declaration( name: string, kind: TypeDeclarationModel['kind'], @@ -684,6 +892,7 @@ function emit( symbol: 'Root', type: 'schema-reference', }], + invocations: [], }], } return new FaceModelEmitter(face).emit('@fixture/schema').js @@ -710,6 +919,7 @@ function schemaFace( symbol, type: 'root', }], + invocations: [], }], } } diff --git a/packages/typert/generator/tests/tools-catalog.spec.ts b/packages/typert/generator/tests/tools-catalog.spec.ts index 29193e66c1..95c1ab09de 100644 --- a/packages/typert/generator/tests/tools-catalog.spec.ts +++ b/packages/typert/generator/tests/tools-catalog.spec.ts @@ -62,7 +62,7 @@ describe('model-driven dsh-tools generation', () => { TYPE_API.find(type => type.name === 'ToolDefinition'), ) - dispose() + await dispose() expect(ctx.typert.getPackage('@deepseek-ai/dsh-tools', 'host')).toBeUndefined() }) }) diff --git a/packages/typert/generator/tests/tsdown-plugin.spec.ts b/packages/typert/generator/tests/tsdown-plugin.spec.ts index 9b4057beee..655636aa79 100644 --- a/packages/typert/generator/tests/tsdown-plugin.spec.ts +++ b/packages/typert/generator/tests/tsdown-plugin.spec.ts @@ -12,6 +12,11 @@ const generated = vi.hoisted(() => vi.fn(() => [ exports: [], js: 'export const host = true\n', dts: 'export declare const host: true\n', + remote: { + js: 'export const remote = true\n', + dts: 'export declare const remote: true\n//# sourceMappingURL=typert.remote-client.d.ts.map\n', + dtsMap: '{"version":3}\n', + }, }, { package: '@deepseek-ai/dsh-tools', @@ -21,10 +26,30 @@ const generated = vi.hoisted(() => vi.fn(() => [ js: 'export const client = true\n', dts: 'export declare const client: true\n', }, + { + package: '@fixture/remote-only', + packageRoot: 'packages/remote-only', + face: 'host' as const, + exports: [], + js: 'export const local = true\n', + dts: 'export declare const local: true\n', + remote: { + js: 'export const remoteOnly = true\n', + dts: 'export declare const remoteOnly: true\n//# sourceMappingURL=typert.remote-client.d.ts.map\n', + dtsMap: '{"version":3}\n', + }, + }, +])) + +const discovered = vi.hoisted(() => vi.fn(() => [ + { package: '@deepseek-ai/dsh-tools', root: 'packages/core/tools', faces: ['host'] }, + { package: '@fixture/ignored', root: 'packages/ignored', faces: ['host'] }, + { package: '@fixture/remote-only', root: 'packages/remote-only', faces: ['host'] }, ])) vi.mock('../src/workspace.ts', () => ({ WorkspaceTypertGenerator: class { + discover = discovered generate = generated }, })) @@ -33,6 +58,7 @@ const { typertPlugin } = await import('../src/tsdown-plugin.ts') const roots: string[] = [] afterEach(() => { + discovered.mockClear() generated.mockClear() for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) @@ -80,9 +106,64 @@ describe('typertPlugin', () => { expect(readFileSync(join(packageLib, 'typert.host.d.ts'), 'utf8')).toBe('export declare const host: true\n') expect(readFileSync(join(packageLib, 'typert.client.js'), 'utf8')).toBe('export const client = true\n') expect(existsSync(join(packageLib, 'typert.client.d.ts'))).toBe(true) + expect(readFileSync(join(packageLib, 'typert.remote-client.js'), 'utf8')).toBe('export const remote = true\n') + expect(readFileSync(join(packageLib, 'typert.remote-client.d.ts'), 'utf8')) + .toBe('export declare const remote: true\n//# sourceMappingURL=typert.remote-client.d.ts.map\n') + expect(readFileSync(join(packageLib, 'typert.remote-client.d.ts.map'), 'utf8')) + .toBe('{"version":3}\n') expect(readFileSync(join(root, 'packages/client-tools/lib/typert.client.js'), 'utf8')) .toBe('export const client = true\n') }) + + it('generates a package opted in only through its Remote export', async () => { + const root = await workspace() + const output = await packageOutput(root, 'remote-only', { + name: '@fixture/remote-only', + exports: { './remote': './lib/typert.remote-client.js' }, + }) + + typertPlugin().writeBundle({ dir: output }) + + const packageLib = join(root, 'packages', 'remote-only', 'lib') + expect(generated).toHaveBeenCalledOnce() + expect(readFileSync(join(packageLib, 'typert.remote-client.js'), 'utf8')) + .toBe('export const remoteOnly = true\n') + expect(readFileSync(join(packageLib, 'typert.remote-client.d.ts'), 'utf8')) + .toBe('export declare const remoteOnly: true\n//# sourceMappingURL=typert.remote-client.d.ts.map\n') + expect(readFileSync(join(packageLib, 'typert.remote-client.d.ts.map'), 'utf8')) + .toBe('{"version":3}\n') + }) + + it('emits every explicit workspace contributor once from a host-only prepass', async () => { + const root = await workspace() + const trigger = await packageOutput(root, 'generator', { name: '@deepseek-ai/dsh-typert-generator' }) + await packageOutput(root, 'core/tools', { + name: '@deepseek-ai/dsh-tools', + exports: { './typert': './lib/typert.host.js' }, + }) + await packageOutput(root, 'ignored', { name: '@fixture/ignored' }) + await packageOutput(root, 'remote-only', { + name: '@fixture/remote-only', + exports: { './remote': './lib/typert.remote-client.js' }, + }) + + const plugin = typertPlugin({ mode: 'workspace', faces: ['host'] }) + plugin.writeBundle({ dir: trigger }) + plugin.writeBundle({ dir: join(root, 'packages/core/tools/lib/dev') }) + + expect(discovered).toHaveBeenCalledOnce() + expect(discovered).toHaveBeenCalledWith(['host']) + expect(generated).toHaveBeenCalledOnce() + expect(generated).toHaveBeenCalledWith( + ['@deepseek-ai/dsh-tools', '@fixture/remote-only'], + ['host'], + ) + expect(readFileSync(join(root, 'packages/core/tools/lib/typert.host.js'), 'utf8')) + .toBe('export const host = true\n') + expect(readFileSync(join(root, 'packages/remote-only/lib/typert.remote-client.js'), 'utf8')) + .toBe('export const remoteOnly = true\n') + expect(existsSync(join(root, 'packages/ignored/lib/typert.host.js'))).toBe(false) + }) }) async function workspace(): Promise { diff --git a/packages/typert/generator/tests/type-model.spec.ts b/packages/typert/generator/tests/type-model.spec.ts index 923c254d0f..ca37cd1bbe 100644 --- a/packages/typert/generator/tests/type-model.spec.ts +++ b/packages/typert/generator/tests/type-model.spec.ts @@ -201,6 +201,53 @@ describe('WorkspaceAnalyzer', { timeout: 60_000 }, () => { expect(batched).toEqual(direct) }) + it('discovers an explicitly keyed service implementation without a Context merge', () => { + const root = copyFixture('explicit-service-') + addExplicitServicePackage(root, 'service detached') + const analyzer = new WorkspaceAnalyzer({ root }) + + expect(analyzer.discoverPackages()).toContainEqual({ + package: '@fixture/explicit-service', + root: 'packages/explicit-service', + faces: ['host'], + }) + const model = new WorkspaceAnalyzer({ root, packages: ['@fixture/explicit-service'] }).analyze() + const service = model.faces[0]?.packages[0]?.services[0] + expect(service).toMatchObject({ key: 'detached', export: { name: 'DetachedService' } }) + }) + + it('prefers an explicitly keyed implementation over its protocol Context merge', () => { + const root = copyFixture('explicit-service-protocol-') + addExplicitServicePackage(root, 'service detached', true) + const model = new WorkspaceAnalyzer({ + root, + packages: ['@fixture/explicit-service'], + }).analyze() + const service = model.faces[0]?.packages[0]?.services[0] + + expect(service).toMatchObject({ + key: 'detached', + export: { name: 'DetachedService' }, + location: { file: 'packages/explicit-service/src/index.ts' }, + }) + }) + + it('rejects an explicit service implementation without one valid key', () => { + const missing = copyFixture('explicit-service-missing-') + addExplicitServicePackage(missing, 'service') + expect(() => new WorkspaceAnalyzer({ + root: missing, + packages: ['@fixture/explicit-service'], + }).analyze()).toThrow('@typert service requires exactly one nonempty Cordis service key') + + const invalid = copyFixture('explicit-service-invalid-') + addExplicitServicePackage(invalid, 'service bad/key') + expect(() => new WorkspaceAnalyzer({ + root: invalid, + packages: ['@fixture/explicit-service'], + }).analyze()).toThrow('@typert service requires exactly one nonempty Cordis service key') + }) + it('indexes authored top-level exports without promoting them to graph roots', () => { const declarations = new WorkspaceAnalyzer({ root: fixtureRoot }).indexSourceDeclarations() const agent = declarations.find(declaration => declaration.name === 'Agent') @@ -1178,6 +1225,57 @@ function addSameFacePackage(root: string, specifier: string, importedName: strin writeFileSync(aggregatePath, `${JSON.stringify(aggregate, null, 2)}\n`) } +function addExplicitServicePackage(root: string, annotation: string, withProtocol = false): void { + const packageRoot = join(root, 'packages/explicit-service') + mkdirSync(join(packageRoot, 'src'), { recursive: true }) + writeFileSync(join(packageRoot, 'package.json'), JSON.stringify({ + name: '@fixture/explicit-service', + private: true, + type: 'module', + exports: { + '.': { + types: './lib/types/index.d.ts', + default: './lib/index.js', + }, + }, + }, null, 2)) + writeFileSync(join(packageRoot, 'tsconfig.json'), JSON.stringify({ + extends: '../../tsconfig.base.json', + compilerOptions: { rootDir: 'src', outDir: 'lib/types' }, + include: ['src'], + }, null, 2)) + if (withProtocol) { + writeFileSync(join(packageRoot, 'src/types.ts'), [ + '/** Public detached Service protocol. */', + 'export interface DetachedProtocol {', + ' /** Report protocol readiness. */', + ' ready(): boolean', + '}', + "declare module 'cordis' {", + ' interface Context { detached: DetachedProtocol }', + '}', + '', + ].join('\n')) + } + writeFileSync(join(packageRoot, 'src/index.ts'), [ + "import { Service } from 'cordis'", + ...(withProtocol ? ["export type { DetachedProtocol } from './types.ts'"] : []), + '/**', + ' * Service implementation discovered independently of its protocol package.', + ` * @typert ${annotation}`, + ' */', + 'export class DetachedService extends Service {', + ' /** Report readiness. */', + ' ready(): boolean { return true }', + '}', + '', + ].join('\n')) + const aggregatePath = join(root, 'tsconfig.host.json') + const aggregate = JSON.parse(readFileSync(aggregatePath, 'utf8')) as { references: { path: string }[] } + aggregate.references.push({ path: './packages/explicit-service' }) + writeFileSync(aggregatePath, `${JSON.stringify(aggregate, null, 2)}\n`) +} + describe('FaceModelEmitter', { timeout: 60_000 }, () => { it('emits runnable Zod JavaScript, precise declarations, and runtime package metadata', async () => { const model = new WorkspaceAnalyzer({ root: fixtureRoot }).analyze() diff --git a/packages/typert/loader/src/index.ts b/packages/typert/loader/src/index.ts index 9485a76f05..fee1098340 100644 --- a/packages/typert/loader/src/index.ts +++ b/packages/typert/loader/src/index.ts @@ -135,6 +135,11 @@ export function validateTypertManifest(pkgName: string, exported: unknown): Type requireMembers(pkgName, object.members, `object "${object.name as string}"`) requireTypes(pkgName, object.types, `object "${object.name as string}"`) } + if (manifest.invocations !== undefined) { + for (const value of requireArray(pkgName, manifest.invocations, 'TYPERT.invocations')) { + requireInvocation(pkgName, value) + } + } return manifest as unknown as TypertContribution } @@ -184,6 +189,88 @@ function requireTypes(pkgName: string, value: unknown, subject: string): void { } } +function requireInvocation(pkgName: string, value: unknown): void { + const invocation = requireObject(pkgName, value, 'invocation') + for (const key of ['id', 'service', 'namespace', 'method'] as const) { + requireString(pkgName, invocation, key, 'invocation') + } + const id = invocation.id as string + const receiver = requireObject(pkgName, invocation.invocation, `invocation "${id}" receiver`) + if (receiver.kind === 'context') { + requireString(pkgName, receiver, 'context', `invocation "${id}" Context receiver`) + requireString(pkgName, receiver, 'wire', `invocation "${id}" Context receiver`) + requireStrictCodec(pkgName, receiver.codec, `invocation "${id}" Context codec`) + } else if (receiver.kind !== 'direct') { + throw new Error(`typert-loader: ${pkgName} invocation "${id}" receiver kind must be "direct" or "context"`) + } + const wires = new Set() + const parameters = new Map>() + let lookupCount = 0 + for (const valueParameter of requireArray(pkgName, invocation.parameters, `invocation "${id}" parameters`)) { + const parameter = requireObject(pkgName, valueParameter, `invocation "${id}" parameter`) + requireString(pkgName, parameter, 'name', `invocation "${id}" parameter`) + requireString(pkgName, parameter, 'wire', `invocation "${id}" parameter`) + const wire = parameter.wire as string + if (wires.has(wire)) { + throw new Error(`typert-loader: ${pkgName} invocation "${id}" repeats wire field "${wire}"`) + } + wires.add(wire) + if (parameter.source === 'lookup') { + lookupCount += 1 + requireString(pkgName, parameter, 'lookup', `invocation "${id}" lookup parameter`) + } else if (parameter.source === 'json') { + if (parameter.lookup !== undefined) { + throw new Error(`typert-loader: ${pkgName} invocation "${id}" JSON parameter declares a lookup`) + } + } else { + throw new Error(`typert-loader: ${pkgName} invocation "${id}" parameter source must be "json" or "lookup"`) + } + parameters.set(wire, parameter) + requireStrictCodec(pkgName, parameter.codec, `invocation "${id}" parameter codec`) + } + if (invocation.scope !== undefined) { + if (receiver.kind !== 'direct') { + throw new Error(`typert-loader: ${pkgName} invocation "${id}" Context receiver cannot declare a direct scope projection`) + } + const scope = requireObject(pkgName, invocation.scope, `invocation "${id}" scope`) + requireString(pkgName, scope, 'context', `invocation "${id}" scope`) + requireString(pkgName, scope, 'wire', `invocation "${id}" scope`) + const parameter = parameters.get(scope.wire as string) + if (lookupCount !== 1 || parameter?.source !== 'lookup' || parameter.lookup !== scope.context) { + throw new Error( + `typert-loader: ${pkgName} invocation "${id}" scope wire "${scope.wire as string}" must select its only lookup parameter`, + ) + } + } + if (receiver.kind === 'context' && wires.has(receiver.wire as string)) { + throw new Error(`typert-loader: ${pkgName} invocation "${id}" repeats Context wire field "${receiver.wire as string}"`) + } + requireStrictCodec(pkgName, invocation.result, `invocation "${id}" result codec`) + if (invocation.sourceLocation !== undefined) { + const location = requireObject(pkgName, invocation.sourceLocation, `invocation "${id}" sourceLocation`) + requireString(pkgName, location, 'file', `invocation "${id}" sourceLocation`) + for (const key of ['line', 'column'] as const) { + if (!Number.isInteger(location[key]) || (location[key] as number) < 1) { + throw new Error(`typert-loader: ${pkgName} invocation "${id}" sourceLocation.${key} must be a positive integer`) + } + } + } +} + +function requireStrictCodec(pkgName: string, value: unknown, subject: string): void { + const codec = requireObject(pkgName, value, subject) + if (codec.mode !== 'strict') { + throw new Error(`typert-loader: ${pkgName} ${subject} must use a strict codec`) + } + requireString(pkgName, codec, 'typeSymbol', subject) + if (typeof codec.schema !== 'object' + || codec.schema === null + || !('_zod' in codec.schema) + || typeof (codec.schema as { parse?: unknown }).parse !== 'function') { + throw new Error(`typert-loader: ${pkgName} ${subject} is not backed by a zod v4 schema`) + } +} + /** * Scan current Loader entries during activation, then follow entry mounts and * unmounts for this plugin's lifetime. @@ -202,7 +289,7 @@ export async function apply(ctx: Context, config: Config): Promise { const configured = new Set((config as ResolvedConfig).packages) // Registered contributions by entry name; the disposer withdraws the entry's registration. - const registered = new Map void>() + const registered = new Map Promise>() // In-flight import/register tasks by entry name. const pending = new Map>() // Artifact paths by package name. Negative verdicts (unresolvable specifier — @@ -279,7 +366,7 @@ export async function apply(ctx: Context, config: Config): Promise { const dispose = registered.get(entryName) if (dispose !== undefined) { registered.delete(entryName) - dispose() + return dispose() } return undefined } diff --git a/packages/typert/loader/tests/loader.spec.ts b/packages/typert/loader/tests/loader.spec.ts index 3b126f1e76..1e7e553605 100644 --- a/packages/typert/loader/tests/loader.spec.ts +++ b/packages/typert/loader/tests/loader.spec.ts @@ -1,4 +1,5 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' @@ -8,6 +9,7 @@ import Loader from '@cordisjs/plugin-loader' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import * as typertLoader from '@deepseek-ai/dsh-typert-loader' import { validateTypertManifest } from '@deepseek-ai/dsh-typert-loader' +import { z } from 'zod' let root: string | undefined let context: Context | undefined @@ -63,12 +65,45 @@ function typertSource(pkgName: string, entryName: string): string { ].join('\n') } +function invocationTypertSource(pkgName: string): string { + return [ + 'import { z } from \'zod\'', + 'const Text = z.string()', + 'export const TYPERT = {', + ` package: '${pkgName}',`, + ' face: \'host\',', + ' schemas: [],', + ' model: { services: [], events: [], objects: [] },', + ' invocations: [{', + ` id: '${pkgName}#goals/create',`, + ' service: \'goals\', namespace: \'goals\', method: \'create\',', + ' invocation: { kind: \'direct\' },', + ' parameters: [{', + ' name: \'request\', wire: \'request\', source: \'json\',', + ` codec: { mode: 'strict', typeSymbol: '${pkgName}/types#Request', schema: Text },`, + ' }],', + ` result: { mode: 'strict', typeSymbol: '${pkgName}/types#Result', schema: Text },`, + ' sourceLocation: { file: \'src/index.ts\', line: 8, column: 3 },', + ' }],', + '}', + '', + ].join('\n') +} + /** Boot a real Loader over a fixture root; plugin modules resolve from its node_modules. */ async function boot(): Promise { context = new Context() context.baseUrl = pathToFileURL(join(root as string, 'cordis.yml')).href await context.plugin(TypertRegistry) await context.plugin(Loader) + const fixtureRequire = createRequire(context.baseUrl) + context.loader.internal = { + version: 'v2', + async import(specifier: string) { + const module: unknown = await import(pathToFileURL(fixtureRequire.resolve(specifier)).href) + return module + }, + } as unknown as NonNullable // zod must be resolvable from the fixture packages; link the workspace copy. await mkdir(join(root as string, 'node_modules'), { recursive: true }) return context @@ -105,6 +140,33 @@ describe('typert loader', () => { expect(ctx.typert.getPackage('@fixture/nested')).toBeUndefined() }) + it('registers a strict invocation into the local registry and withdraws it with the loader', LOADER_TEST_TIMEOUT, async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-typert-loader-')) + await linkZod(root) + await writePackage(root, '@fixture/invocation', { + typertSource: invocationTypertSource('@fixture/invocation'), + }) + const ctx = await boot() + + const fiber = mountTypertLoader(ctx, { packages: ['@fixture/invocation'] }) + await fiber + + const descriptor = ctx.typert.local.get('goals/create') + expect(descriptor).toMatchObject({ + id: '@fixture/invocation#goals/create', + invocation: { kind: 'direct' }, + parameters: [{ wire: 'request', source: 'json' }], + sourceLocation: { file: 'src/index.ts', line: 8, column: 3 }, + }) + expect(descriptor?.parameters[0]?.codec.mode).toBe('strict') + if (descriptor?.parameters[0]?.codec.mode === 'strict') { + expect(descriptor.parameters[0].codec.schema.parse('request')).toBe('request') + } + + await fiber.dispose() + expect(ctx.typert.local.get('goals/create')).toBeUndefined() + }) + it('fails loud when an explicit package is absent or has no Typert export', LOADER_TEST_TIMEOUT, async () => { root = await mkdtemp(join(tmpdir(), 'dsh-typert-loader-')) await writePackage(root, '@fixture/plain') @@ -427,8 +489,156 @@ describe('validateTypertManifest', () => { model: { ...complete.model, objects: [{ ...complete.model.objects[0], exportName: '' }] }, })).toThrow('object has a missing or empty exportName') }) + + it('validates strict invocation descriptors and accepts legacy manifests without them', () => { + const legacy = completeManifest(zodish) + expect(validateTypertManifest('pkg', legacy)).toBe(legacy) + + const descriptor = strictInvocation() + const manifest = { ...legacy, invocations: [descriptor] } + expect(validateTypertManifest('pkg', manifest)).toBe(manifest) + const scoped = { + ...descriptor, + scope: { context: 'agent', wire: 'agentId' }, + parameters: [{ + name: 'agent', + wire: 'agentId', + source: 'lookup', + lookup: 'agent', + codec: strictCodec('pkg#AgentId'), + }, ...descriptor.parameters], + } + expect(validateTypertManifest('pkg', { ...legacy, invocations: [scoped] }).invocations) + .toEqual([scoped]) + + expect(() => validateTypertManifest('pkg', { ...legacy, invocations: {} })) + .toThrow('TYPERT.invocations must be an array') + expect(() => validateTypertManifest('pkg', { + ...legacy, + invocations: [{ ...descriptor, invocation: { kind: 'future' } }], + })).toThrow('receiver kind must be "direct" or "context"') + expect(() => validateTypertManifest('pkg', { + ...legacy, + invocations: [{ ...descriptor, result: { mode: 'src-json' } }], + })).toThrow('result codec must use a strict codec') + expect(() => validateTypertManifest('pkg', { + ...legacy, + invocations: [{ ...descriptor, result: { mode: 'strict', typeSymbol: 'pkg#Result', schema: zodish } }], + })).toThrow('result codec is not backed by a zod v4 schema') + expect(() => validateTypertManifest('pkg', { + ...legacy, + invocations: [{ + ...descriptor, + parameters: [{ ...descriptor.parameters[0], source: 'future' }], + }], + })).toThrow('parameter source must be "json" or "lookup"') + expect(() => validateTypertManifest('pkg', { + ...legacy, + invocations: [{ + ...descriptor, + parameters: [{ ...descriptor.parameters[0], source: 'lookup' }], + }], + })).toThrow('lookup parameter has a missing or empty lookup') + expect(() => validateTypertManifest('pkg', { + ...legacy, + invocations: [{ + ...descriptor, + parameters: [{ ...descriptor.parameters[0], lookup: 'agent' }], + }], + })).toThrow('JSON parameter declares a lookup') + expect(() => validateTypertManifest('pkg', { + ...legacy, + invocations: [{ + ...descriptor, + parameters: [descriptor.parameters[0], { ...descriptor.parameters[0], name: 'again' }], + }], + })).toThrow('repeats wire field "request"') + expect(() => validateTypertManifest('pkg', { + ...legacy, + invocations: [{ + ...descriptor, + invocation: { + kind: 'context', + context: 'agent', + wire: 'request', + codec: strictCodec('pkg#AgentId'), + }, + }], + })).toThrow('repeats Context wire field "request"') + expect(() => validateTypertManifest('pkg', { + ...legacy, + invocations: [{ ...scoped, scope: null }], + })).toThrow('scope must be an object') + expect(() => validateTypertManifest('pkg', { + ...legacy, + invocations: [{ ...scoped, scope: { wire: 'agentId' } }], + })).toThrow('scope has a missing or empty context') + expect(() => validateTypertManifest('pkg', { + ...legacy, + invocations: [{ ...scoped, scope: { context: 'agent' } }], + })).toThrow('scope has a missing or empty wire') + expect(() => validateTypertManifest('pkg', { + ...legacy, + invocations: [{ + ...scoped, + invocation: { + kind: 'context', + context: 'agent', + wire: 'scopeId', + codec: strictCodec('pkg#AgentId'), + }, + }], + })).toThrow('Context receiver cannot declare a direct scope projection') + expect(() => validateTypertManifest('pkg', { + ...legacy, + invocations: [{ ...scoped, scope: { context: 'agent', wire: 'missingId' } }], + })).toThrow('must select its only lookup parameter') + expect(() => validateTypertManifest('pkg', { + ...legacy, + invocations: [{ + ...scoped, + parameters: [...scoped.parameters, { + name: 'other', + wire: 'otherId', + source: 'lookup', + lookup: 'agent', + codec: strictCodec('pkg#AgentId'), + }], + }], + })).toThrow('must select its only lookup parameter') + expect(() => validateTypertManifest('pkg', { + ...legacy, + invocations: [{ ...scoped, scope: { context: 'other', wire: 'agentId' } }], + })).toThrow('must select its only lookup parameter') + expect(() => validateTypertManifest('pkg', { + ...legacy, + invocations: [{ ...descriptor, sourceLocation: { file: 'src/index.ts', line: 0, column: 1 } }], + })).toThrow('sourceLocation.line must be a positive integer') + }) }) +function strictCodec(typeSymbol: string) { + return { mode: 'strict', typeSymbol, schema: z.string() } +} + +function strictInvocation() { + return { + id: 'pkg#goals/create', + service: 'goals', + namespace: 'goals', + method: 'create', + invocation: { kind: 'direct' }, + parameters: [{ + name: 'request', + wire: 'request', + source: 'json', + codec: strictCodec('pkg#Request'), + }], + result: strictCodec('pkg#Result'), + sourceLocation: { file: 'src/index.ts', line: 1, column: 1 }, + } +} + function completeManifest(zodish: object) { const member = { name: 'member', signature: 'member(): void', kind: 'method' } const type = { name: 'Value', declaration: 'export interface Value {}' } diff --git a/packages/typert/registry/package.json b/packages/typert/registry/package.json index b543589dc6..e912808293 100644 --- a/packages/typert/registry/package.json +++ b/packages/typert/registry/package.json @@ -15,6 +15,10 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, "./types": { "types": "./lib/types/types.d.ts", "default": "./lib/types/types.js" @@ -22,14 +26,25 @@ "./src/*": "./src/*", "./package.json": "./package.json" }, + "dshClient": { + "inject": [], + "platform": "web", + "immediately": true + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, "files": [ "lib/index.js", "lib/invariant.js", + "lib/client.js", "lib/types/**/*.js", "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { + "@deepseek-ai/dsh-type-meta": "workspace:^", "zod": "^4.4.3" }, "peerDependencies": { diff --git a/packages/typert/registry/src/client/index.ts b/packages/typert/registry/src/client/index.ts new file mode 100644 index 0000000000..e468e78999 --- /dev/null +++ b/packages/typert/registry/src/client/index.ts @@ -0,0 +1,15 @@ +/** Browser face of the shared TypeRT runtime registry. */ + +import type { Context } from 'cordis' +import { TypertRegistry } from '../service.ts' + +/** Required services: none; this is the Client reflection root. */ +export const inject: string[] = [] + +/** + * Install the same registry implementation used by the Host face. + * @param ctx - Client Cordis root. + */ +export function apply(ctx: Context): void { + new TypertRegistry(ctx) +} diff --git a/packages/typert/registry/src/index.ts b/packages/typert/registry/src/index.ts index 91a8383594..3619c02dff 100644 --- a/packages/typert/registry/src/index.ts +++ b/packages/typert/registry/src/index.ts @@ -1,12 +1,7 @@ -/** - * Runtime registry for generated Typert contributions. It owns live Zod - * instances and generated package reflection, but performs no TypeScript - * analysis or schema generation. - * @module @deepseek-ai/dsh-typert-registry - */ +/** Host entry for the shared TypeRT runtime registry. */ -import { Context, Service } from 'cordis' -import { z } from 'zod' +import type { z } from 'zod' +import type { TypeRTDisposer } from '@deepseek-ai/dsh-type-meta' import type { TypertContribution, TypertFace, @@ -16,204 +11,17 @@ import type { TypertSchemaRecord, } from './types.ts' -export type { - TypertContribution, - TypertDocTag, - TypertDocumentation, - TypertEventModel, - TypertFace, - TypertMemberModel, - TypertObjectModel, - TypertPackageFilter, - TypertPackageModel, - TypertPackageRecord, - TypertSchema, - TypertSchemaFilter, - TypertSchemaRecord, - TypertServiceModel, - TypertTypeModel, -} from './types.ts' +export { default, TypertRegistry, typertEndpoint, typertKey, typertPackageKey } from './service.ts' +export type * from './types.ts' -declare module 'cordis' { - interface Context { - typert: TypertRegistry +declare module '@deepseek-ai/dsh-type-meta' { + interface TypeRTService { + register(contribution: TypertContribution): TypeRTDisposer + get(key: string): TypertSchemaRecord | undefined + resolve(key: string): TypertSchemaRecord + list(filter?: TypertSchemaFilter): TypertSchemaRecord[] + getPackage(packageName: string, face?: TypertFace): TypertPackageRecord | undefined + listPackages(filter?: TypertPackageFilter): TypertPackageRecord[] + toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema.BaseSchema } } - -/** - * Compose the global key of one generated schema. - * @param packageName - contributing npm package. - * @param name - schema export name. - * @returns `#`. - */ -export function typertKey(packageName: string, name: string): string { - return `${packageName}#${name}` -} - -/** - * Compose the identity of one package-face model. - * @param packageName - contributing npm package. - * @param face - independently compiled face. - * @returns `#`. - */ -export function typertPackageKey(packageName: string, face: TypertFace): string { - return `${packageName}#${face}` -} - -/** - * Registry of generated schemas and package reflection. - * @typert service - */ -export class TypertRegistry extends Service { - private readonly schemas = new Map() - private readonly packages = new Map() - - constructor(ctx: Context) { - super(ctx, 'typert') - } - - /** - * Register one generated contribution atomically for the calling fiber. - * Duplicate package-face identities or schema keys reject the whole batch. - * @param contribution - generated schemas and package metadata. - * @returns the exact effect disposer that removes this contribution. - */ - register(contribution: TypertContribution): () => void { - const packageRecord = this.validatePackage(contribution) - const schemaRecords = this.validateSchemas(contribution) - const { schemas, packages } = this - const dispose = this.ctx.effect(function* () { - packages.set(packageRecord.key, packageRecord) - for (const record of schemaRecords) schemas.set(record.key, record) - yield () => { - packages.delete(packageRecord.key) - for (const record of schemaRecords) schemas.delete(record.key) - } - }, 'typert.register()') - // oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; preserve Cordis disposer identity - return dispose - } - - /** - * Look up one schema by `#`. - * @param key - global schema key. - * @returns the live schema record, or `undefined` when absent. - */ - get(key: string): TypertSchemaRecord | undefined { - return this.schemas.get(key) - } - - /** - * Resolve one required schema. - * @param key - global schema key. - * @returns the live schema record. - * @throws when the key is malformed, the package face is absent, or the schema is not contributed. - */ - resolve(key: string): TypertSchemaRecord { - const record = this.schemas.get(key) - if (record !== undefined) return record - const hash = key.indexOf('#') - if (hash <= 0 || hash === key.length - 1) { - throw new Error(`typert: invalid schema key "${key}" — expected "#"`) - } - const packageName = key.slice(0, hash) - if ([...this.packages.values()].some(candidate => candidate.package === packageName)) { - throw new Error( - `typert: cannot resolve "${key}" — package "${packageName}" is registered but contributes no schema named "${key.slice(hash + 1)}"`, - ) - } - throw new Error(`typert: cannot resolve "${key}" — package "${packageName}" has no registered contribution`) - } - - /** - * Enumerate live schemas in registration order. - * @param filter - optional package and face restriction. - * @returns matching schema records. - */ - list(filter: TypertSchemaFilter = {}): TypertSchemaRecord[] { - return [...this.schemas.values()].filter(record => matches(record, filter)) - } - - /** - * Look up generated reflection for one package face. - * @param packageName - exact npm package name. - * @param face - face to query; defaults to the host runtime. - * @returns the live package record, or `undefined` when absent. - */ - getPackage(packageName: string, face: TypertFace = 'host'): TypertPackageRecord | undefined { - return this.packages.get(typertPackageKey(packageName, face)) - } - - /** - * Enumerate generated package reflection in registration order. - * @param filter - optional package and face restriction. - * @returns matching package records. - */ - listPackages(filter: TypertPackageFilter = {}): TypertPackageRecord[] { - return [...this.packages.values()].filter(record => matches(record, filter)) - } - - /** - * Project a live Zod schema to JSON Schema without caching the result. - * @param key - global schema key. - * @param params - Zod projection parameters. - * @returns a fresh JSON Schema document. - */ - toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema.BaseSchema { - return z.toJSONSchema(this.resolve(key).schema, params) - } - - private validatePackage(contribution: TypertContribution): TypertPackageRecord { - validateSegment('package name', contribution.package) - const face: unknown = contribution.face - if (face !== 'host' && face !== 'client') { - throw new Error(`typert: invalid face ${JSON.stringify(face)} — expected "host" or "client"`) - } - const key = typertPackageKey(contribution.package, contribution.face) - if (this.packages.has(key)) { - throw new Error(`typert: package face "${key}" is already registered`) - } - return { - package: contribution.package, - face, - key, - model: contribution.model, - } - } - - private validateSchemas(contribution: TypertContribution): TypertSchemaRecord[] { - const records: TypertSchemaRecord[] = [] - const batch = new Set() - for (const schema of contribution.schemas) { - validateSegment('schema name', schema.name) - const key = typertKey(contribution.package, schema.name) - if (batch.has(key) || this.schemas.has(key)) { - throw new Error(`typert: schema "${key}" is already registered`) - } - batch.add(key) - records.push({ - ...schema, - package: contribution.package, - face: contribution.face, - key, - }) - } - return records - } -} - -function matches( - record: { readonly package: string; readonly face: TypertFace }, - filter: { readonly package?: string; readonly face?: TypertFace }, -): boolean { - return (filter.package === undefined || record.package === filter.package) - && (filter.face === undefined || record.face === filter.face) -} - -function validateSegment(subject: string, value: string): void { - if (value.length === 0 || value.includes('#')) { - throw new Error(`typert: invalid ${subject} "${value}" — must be nonempty and must not contain "#"`) - } -} - -export default TypertRegistry diff --git a/packages/typert/registry/src/service.ts b/packages/typert/registry/src/service.ts new file mode 100644 index 0000000000..16160a3860 --- /dev/null +++ b/packages/typert/registry/src/service.ts @@ -0,0 +1,584 @@ +/** + * Runtime registry for generated TypeRT reflection, Remote invocations, and + * dependency-inverted lookup/Context providers. It performs no TypeScript + * analysis or schema generation. + * @module @deepseek-ai/dsh-typert-registry + */ + +import { Context, Service } from 'cordis' +import { z } from 'zod' +import type { + InvocationDescriptor, + TypeRTClientContextBinder, + TypeRTContextMap, + TypeRTContextRegistry, + TypeRTContextWire, + TypeRTDisposer, + TypeRTHostContextProvider, + TypeRTLocalRegistry, + TypeRTLookupHost, + TypeRTLookupMap, + TypeRTLookupProvider, + TypeRTLookupRegistry, + TypeRTLookupWire, + TypeRTRemoteContribution, + TypeRTRemoteRegistry, + TypeRTRegistryChange, + TypeRTRegistryListener, + TypeRTService, +} from '@deepseek-ai/dsh-type-meta' +import type { + TypertContribution, + TypertFace, + TypertPackageFilter, + TypertPackageRecord, + TypertSchemaFilter, + TypertSchemaRecord, +} from './types.ts' + +/** + * Compose the global key of one generated schema. + * @param packageName - contributing npm package. + * @param name - schema export name. + * @returns `#`. + */ +export function typertKey(packageName: string, name: string): string { + return `${packageName}#${name}` +} + +/** + * Compose the identity of one package-face model. + * @param packageName - contributing npm package. + * @param face - independently compiled face. + * @returns `#`. + */ +export function typertPackageKey(packageName: string, face: TypertFace): string { + return `${packageName}#${face}` +} + +/** + * Compose the endpoint key used by local and Remote invocation registries. + * @param descriptor - invocation whose namespace and method form the endpoint. + * @returns `/`. + */ +export function typertEndpoint(descriptor: Pick): string { + return `${descriptor.namespace}/${descriptor.method}` +} + +interface DescriptorEntry { + readonly descriptor: InvocationDescriptor + readonly owner: object +} + +interface ProviderEntry { + readonly provider: Provider + readonly owner: object +} + +type ReportObserverError = (change: TypeRTRegistryChange, error: unknown) => void + +class ChangeSource { + private readonly listeners = new Set() + + constructor(private readonly report: ReportObserverError) {} + + subscribe(ctx: Context, listener: TypeRTRegistryListener): TypeRTDisposer { + const { listeners } = this + return ctx.effect(function* () { + listeners.add(listener) + yield () => { listeners.delete(listener) } + }, 'typert registry subscription') + } + + emit(change: TypeRTRegistryChange): void { + for (const listener of [...this.listeners]) { + try { + listener(change) + } catch (error) { + this.report(change, error) + } + } + } +} + +class DescriptorStore { + private readonly entries = new Map() + private readonly ids = new Map() + private readonly history = new Set() + private readonly changes: ChangeSource + + constructor( + private readonly kind: 'local' | 'remote', + report: ReportObserverError, + ) { + this.changes = new ChangeSource(report) + } + + validate(descriptors: readonly InvocationDescriptor[]): void { + const endpoints = new Set() + const ids = new Set() + for (const descriptor of descriptors) { + validateInvocation(descriptor) + const endpoint = typertEndpoint(descriptor) + if (endpoints.has(endpoint) || this.entries.has(endpoint)) { + throw new Error(`typert: ${this.kind} endpoint "${endpoint}" is already registered`) + } + if (ids.has(descriptor.id) || this.ids.has(descriptor.id)) { + throw new Error(`typert: ${this.kind} invocation id "${descriptor.id}" is already registered`) + } + endpoints.add(endpoint) + ids.add(descriptor.id) + } + } + + commit(owner: object, descriptors: readonly InvocationDescriptor[]): void { + for (const descriptor of descriptors) { + const entry = { descriptor, owner } + const endpoint = typertEndpoint(descriptor) + this.entries.set(endpoint, entry) + this.ids.set(descriptor.id, entry) + this.history.add(endpoint) + } + for (const descriptor of descriptors) { + this.changes.emit({ kind: this.kind, key: typertEndpoint(descriptor) }) + } + } + + withdraw(owner: object, descriptors: readonly InvocationDescriptor[]): void { + const removed: string[] = [] + for (const descriptor of descriptors) { + const endpoint = typertEndpoint(descriptor) + const entry = this.entries.get(endpoint) + if (entry?.owner !== owner) continue + this.entries.delete(endpoint) + if (this.ids.get(descriptor.id) === entry) this.ids.delete(descriptor.id) + removed.push(endpoint) + } + for (const endpoint of removed) this.changes.emit({ kind: this.kind, key: endpoint }) + } + + get(endpoint: string): InvocationDescriptor | undefined { + return this.entries.get(endpoint)?.descriptor + } + + hasSeen(endpoint: string): boolean { + return this.history.has(endpoint) + } + + list(): readonly InvocationDescriptor[] { + return [...this.entries.values()].map(entry => entry.descriptor) + } + + subscribe(ctx: Context, listener: TypeRTRegistryListener): TypeRTDisposer { + return this.changes.subscribe(ctx, listener) + } +} + +class RemoteStore { + private readonly packages = new Map() + + constructor(private readonly descriptors: DescriptorStore) {} + + view(ctx: Context): TypeRTRemoteRegistry { + return { + register: contribution => this.register(ctx, contribution), + get: endpoint => this.descriptors.get(endpoint), + list: () => this.descriptors.list(), + subscribe: listener => this.descriptors.subscribe(ctx, listener), + } + } + + private register(ctx: Context, contribution: TypeRTRemoteContribution): TypeRTDisposer { + validateSegment('Remote package name', contribution.package) + if (this.packages.has(contribution.package)) { + throw new Error(`typert: Remote package "${contribution.package}" is already registered`) + } + this.descriptors.validate(contribution.descriptors) + const owner = {} + const { packages, descriptors } = this + return ctx.effect(function* () { + packages.set(contribution.package, owner) + descriptors.commit(owner, contribution.descriptors) + yield () => { + if (packages.get(contribution.package) === owner) packages.delete(contribution.package) + descriptors.withdraw(owner, contribution.descriptors) + } + }, `typert.remotes.register(${JSON.stringify(contribution.package)})`) + } +} + +class LookupStore { + private readonly providers = new Map>() + private readonly changes: ChangeSource + + constructor(report: ReportObserverError) { + this.changes = new ChangeSource(report) + } + + view(ctx: Context): TypeRTLookupRegistry { + return { + register: >( + key: K, + provider: TypeRTLookupProvider< + TypeRTLookupHost, + TypeRTLookupWire + >, + ) => this.register(ctx, key, provider), + get: key => this.providers.get(key)?.provider, + keys: () => [...this.providers.keys()], + subscribe: listener => this.changes.subscribe(ctx, listener), + } + } + + private register(ctx: Context, key: string, provider: TypeRTLookupProvider): TypeRTDisposer { + validateSegment('lookup key', key) + validateSegment('lookup parameter', provider.parameter) + validateWireName('lookup wire field', provider.wire) + validateNonempty('lookup Host type symbol', provider.hostTypeSymbol) + validateNonempty('lookup wire type symbol', provider.wireTypeSymbol) + if (this.providers.has(key)) throw new Error(`typert: lookup "${key}" is already registered`) + const owner = {} + const entry: ProviderEntry = { provider, owner } + const { providers, changes } = this + return ctx.effect(function* () { + providers.set(key, entry) + changes.emit({ kind: 'lookup', key }) + yield () => { + if (providers.get(key) !== entry) return + providers.delete(key) + changes.emit({ kind: 'lookup', key }) + } + }, `typert.lookups.register(${JSON.stringify(key)})`) + } +} + +class ContextStore { + private readonly hosts = new Map>() + private readonly clients = new Map>() + private readonly changes: ChangeSource + + constructor(report: ReportObserverError) { + this.changes = new ChangeSource(report) + } + + view(ctx: Context): TypeRTContextRegistry { + return { + registerHost: >( + key: K, + provider: TypeRTHostContextProvider>, + ) => this.registerHost(ctx, key, provider), + registerClient: >( + key: K, + binder: TypeRTClientContextBinder>, + ) => this.registerClient(ctx, key, binder), + getHost: key => this.hosts.get(key)?.provider, + getClient: key => this.clients.get(key)?.provider, + subscribe: listener => this.changes.subscribe(ctx, listener), + } + } + + private registerHost(ctx: Context, key: string, provider: TypeRTHostContextProvider): TypeRTDisposer { + validateSegment('Context key', key) + validateWireName('Context wire field', provider.wire) + validateNonempty('Context wire type symbol', provider.wireTypeSymbol) + return this.registerProvider(ctx, this.hosts, 'host-context', key, provider) + } + + private registerClient(ctx: Context, key: string, binder: TypeRTClientContextBinder): TypeRTDisposer { + validateSegment('Context key', key) + return this.registerProvider(ctx, this.clients, 'client-context', key, binder) + } + + private registerProvider( + ctx: Context, + table: Map>, + kind: 'host-context' | 'client-context', + key: string, + provider: Provider, + ): TypeRTDisposer { + if (table.has(key)) throw new Error(`typert: ${kind} provider "${key}" is already registered`) + const entry: ProviderEntry = { provider, owner: {} } + const { changes } = this + return ctx.effect(function* () { + table.set(key, entry) + changes.emit({ kind, key }) + yield () => { + if (table.get(key) !== entry) return + table.delete(key) + changes.emit({ kind, key }) + } + }, `typert.contexts.register(${JSON.stringify(key)})`) + } +} + +/** + * Registry of generated schemas, package reflection, invocations, and Remote + * dependency providers. + * @typert service typert + */ +export class TypertRegistry extends Service implements TypeRTService { + private readonly schemas = new Map() + private readonly packages = new Map() + private readonly localStore: DescriptorStore + private readonly remoteStore: RemoteStore + private readonly lookupStore: LookupStore + private readonly contextStore: ContextStore + + constructor(ctx: Context) { + super(ctx, 'typert') + const report: ReportObserverError = (change, error) => { + ctx.logger.warn(`typert: ${change.kind} observer for "${change.key}" failed`) + ctx.logger.warn(error) + } + this.localStore = new DescriptorStore('local', report) + this.remoteStore = new RemoteStore(new DescriptorStore('remote', report)) + this.lookupStore = new LookupStore(report) + this.contextStore = new ContextStore(report) + } + + /** Current-environment invocation definitions. */ + get local(): TypeRTLocalRegistry { + const ctx = this.ctx + return { + get: endpoint => this.localStore.get(endpoint), + hasSeen: endpoint => this.localStore.hasSeen(endpoint), + list: () => this.localStore.list(), + subscribe: listener => this.localStore.subscribe(ctx, listener), + } + } + + /** Consumer-selected Remote definitions. */ + get remotes(): TypeRTRemoteRegistry { + return this.remoteStore.view(this.ctx) + } + + /** Host object lookup providers. */ + get lookups(): TypeRTLookupRegistry { + return this.lookupStore.view(this.ctx) + } + + /** Host Context providers and Client Context binders. */ + get contexts(): TypeRTContextRegistry { + return this.contextStore.view(this.ctx) + } + + /** + * Register one generated contribution atomically for the calling fiber. + * Duplicate package-face identities, schemas, invocation ids, or endpoints + * reject the whole batch. + * @param contribution - generated schemas, reflection, and Host invocations. + * @returns the exact effect disposer that removes this contribution. + */ + register(contribution: TypertContribution): TypeRTDisposer { + const packageRecord = this.validatePackage(contribution) + const schemaRecords = this.validateSchemas(contribution) + const invocations = contribution.invocations ?? [] + this.localStore.validate(invocations) + const owner = {} + const { schemas, packages, localStore } = this + return this.ctx.effect(function* () { + packages.set(packageRecord.key, packageRecord) + for (const record of schemaRecords) schemas.set(record.key, record) + localStore.commit(owner, invocations) + yield () => { + if (packages.get(packageRecord.key) === packageRecord) packages.delete(packageRecord.key) + for (const record of schemaRecords) { + if (schemas.get(record.key) === record) schemas.delete(record.key) + } + localStore.withdraw(owner, invocations) + } + }, 'typert.register()') + } + + /** + * Look up one schema by `#`. + * @param key - global schema key. + * @returns the live schema record, or `undefined` when absent. + */ + get(key: string): TypertSchemaRecord | undefined { + return this.schemas.get(key) + } + + /** + * Resolve one required schema. + * @param key - global schema key. + * @returns the live schema record. + * @throws when the key is malformed, the package face is absent, or the schema is not contributed. + */ + resolve(key: string): TypertSchemaRecord { + const record = this.schemas.get(key) + if (record !== undefined) return record + const hash = key.indexOf('#') + if (hash <= 0 || hash === key.length - 1) { + throw new Error(`typert: invalid schema key "${key}" — expected "#"`) + } + const packageName = key.slice(0, hash) + if ([...this.packages.values()].some(candidate => candidate.package === packageName)) { + throw new Error( + `typert: cannot resolve "${key}" — package "${packageName}" is registered but contributes no schema named "${key.slice(hash + 1)}"`, + ) + } + throw new Error(`typert: cannot resolve "${key}" — package "${packageName}" has no registered contribution`) + } + + /** + * Enumerate live schemas in registration order. + * @param filter - optional package and face restriction. + * @returns matching schema records. + */ + list(filter: TypertSchemaFilter = {}): TypertSchemaRecord[] { + return [...this.schemas.values()].filter(record => matches(record, filter)) + } + + /** + * Look up generated reflection for one package face. + * @param packageName - exact npm package name. + * @param face - face to query; defaults to the host runtime. + * @returns the live package record, or `undefined` when absent. + */ + getPackage(packageName: string, face: TypertFace = 'host'): TypertPackageRecord | undefined { + return this.packages.get(typertPackageKey(packageName, face)) + } + + /** + * Enumerate generated package reflection in registration order. + * @param filter - optional package and face restriction. + * @returns matching package records. + */ + listPackages(filter: TypertPackageFilter = {}): TypertPackageRecord[] { + return [...this.packages.values()].filter(record => matches(record, filter)) + } + + /** + * Project a live Zod schema to JSON Schema without caching the result. + * @param key - global schema key. + * @param params - Zod projection parameters. + * @returns a fresh JSON Schema document. + */ + toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema.BaseSchema { + return z.toJSONSchema(this.resolve(key).schema, params) + } + + private validatePackage(contribution: TypertContribution): TypertPackageRecord { + validateSegment('package name', contribution.package) + const face: unknown = contribution.face + if (face !== 'host' && face !== 'client') { + throw new Error(`typert: invalid face ${JSON.stringify(face)} — expected "host" or "client"`) + } + const key = typertPackageKey(contribution.package, contribution.face) + if (this.packages.has(key)) { + throw new Error(`typert: package face "${key}" is already registered`) + } + return { + package: contribution.package, + face, + key, + model: contribution.model, + } + } + + private validateSchemas(contribution: TypertContribution): TypertSchemaRecord[] { + const records: TypertSchemaRecord[] = [] + const batch = new Set() + for (const schema of contribution.schemas) { + validateSegment('schema name', schema.name) + const key = typertKey(contribution.package, schema.name) + if (batch.has(key) || this.schemas.has(key)) { + throw new Error(`typert: schema "${key}" is already registered`) + } + batch.add(key) + records.push({ + ...schema, + package: contribution.package, + face: contribution.face, + key, + }) + } + return records + } +} + +function matches( + record: { readonly package: string; readonly face: TypertFace }, + filter: { readonly package?: string; readonly face?: TypertFace }, +): boolean { + return (filter.package === undefined || record.package === filter.package) + && (filter.face === undefined || record.face === filter.face) +} + +function validateInvocation(descriptor: InvocationDescriptor): void { + validateNonempty('invocation id', descriptor.id) + validateSegment('invocation service key', descriptor.service) + validateWireName('invocation namespace', descriptor.namespace) + validateWireName('invocation method', descriptor.method) + if (descriptor.implementation !== undefined) { + validateWireName('invocation implementation method', descriptor.implementation) + } + validateCodec(descriptor.result, `${descriptor.id} result`) + const wires = new Set() + for (const parameter of descriptor.parameters) { + validateWireName('parameter name', parameter.name) + validateWireName('parameter wire field', parameter.wire) + if (wires.has(parameter.wire)) { + throw new Error(`typert: invocation "${descriptor.id}" repeats wire field "${parameter.wire}"`) + } + wires.add(parameter.wire) + if (parameter.source === 'lookup') { + if (parameter.lookup === undefined) { + throw new Error(`typert: invocation "${descriptor.id}" lookup parameter "${parameter.name}" has no lookup key`) + } + validateSegment('lookup key', parameter.lookup) + } else if (parameter.lookup !== undefined) { + throw new Error(`typert: invocation "${descriptor.id}" JSON parameter "${parameter.name}" declares a lookup key`) + } + validateCodec(parameter.codec, `${descriptor.id} parameter ${parameter.name}`) + } + if (descriptor.scope !== undefined) { + if (descriptor.invocation.kind !== 'direct') { + throw new Error(`typert: invocation "${descriptor.id}" Context receiver cannot declare a direct scope projection`) + } + validateSegment('scope Context key', descriptor.scope.context) + validateWireName('scope wire field', descriptor.scope.wire) + const lookups = descriptor.parameters.filter(candidate => candidate.source === 'lookup') + const parameter = lookups.length === 1 ? lookups[0] : undefined + if (parameter === undefined || parameter.wire !== descriptor.scope.wire + || parameter.lookup !== descriptor.scope.context) { + throw new Error( + `typert: invocation "${descriptor.id}" scope wire "${descriptor.scope.wire}" must select its only lookup parameter`, + ) + } + } + if (descriptor.invocation.kind === 'context') { + validateSegment('Context key', descriptor.invocation.context) + validateWireName('Context wire field', descriptor.invocation.wire) + if (wires.has(descriptor.invocation.wire)) { + throw new Error(`typert: invocation "${descriptor.id}" repeats wire field "${descriptor.invocation.wire}"`) + } + validateCodec(descriptor.invocation.codec, `${descriptor.id} Context`) + } +} + +function validateCodec(codec: InvocationDescriptor['result'], subject: string): void { + if (codec.mode === 'src-json') return + validateNonempty(`${subject} type symbol`, codec.typeSymbol) + if (typeof codec.schema.parse !== 'function') { + throw new Error(`typert: ${subject} strict codec has no parse() method`) + } +} + +function validateWireName(subject: string, value: string): void { + validateSegment(subject, value) + if (value.includes('/')) throw new Error(`typert: invalid ${subject} "${value}" — must not contain "/"`) +} + +function validateSegment(subject: string, value: string): void { + if (value.length === 0 || value.includes('#')) { + throw new Error(`typert: invalid ${subject} "${value}" — must be nonempty and must not contain "#"`) + } +} + +function validateNonempty(subject: string, value: string): void { + if (value.length === 0) throw new Error(`typert: invalid ${subject} — must be nonempty`) +} + +export default TypertRegistry diff --git a/packages/typert/registry/src/types.ts b/packages/typert/registry/src/types.ts index 2fb29f5024..6ba0e0f1f2 100644 --- a/packages/typert/registry/src/types.ts +++ b/packages/typert/registry/src/types.ts @@ -5,6 +5,7 @@ */ import type { z } from 'zod' +import type { InvocationDescriptor } from '@deepseek-ai/dsh-type-meta' /** Independently compiled side that produced a contribution. */ export type TypertFace = 'host' | 'client' @@ -82,6 +83,13 @@ export interface TypertContribution { readonly face: TypertFace readonly schemas: readonly TypertSchema[] readonly model: TypertPackageModel + /** Host invocation definitions; absent on artifacts generated before Remote support. */ + readonly invocations?: readonly InvocationDescriptor[] +} + +/** Generated Host contribution with strict Remote invocation definitions. */ +export interface TypertLocalContribution extends TypertContribution { + readonly invocations: readonly InvocationDescriptor[] } /** A live schema plus its contribution identity. */ diff --git a/packages/typert/registry/tests/typert.spec.ts b/packages/typert/registry/tests/typert.spec.ts index 06eb9c107e..a98f99f912 100644 --- a/packages/typert/registry/tests/typert.spec.ts +++ b/packages/typert/registry/tests/typert.spec.ts @@ -2,10 +2,27 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { z } from 'zod' import TypertRegistry, { + typertEndpoint, typertKey, typertPackageKey, type TypertContribution, } from '@deepseek-ai/dsh-typert-registry' +import type { + InvocationDescriptor, + TypeRTContext, + TypeRTLookup, + TypeRTRemoteContribution, +} from '@deepseek-ai/dsh-type-meta' + +declare module '@deepseek-ai/dsh-type-meta' { + interface TypeRTLookupMap { + fixture: TypeRTLookup<{ readonly id: string }, string> + } + + interface TypeRTContextMap { + registryFixture: TypeRTContext + } +} async function makeCtx(): Promise { const ctx = new Context() @@ -42,6 +59,42 @@ function toolsContribution(schema: z.ZodType = z.object({ name: z.string() })): } } +function invocation(id = '@fixture/remote#goals/create'): InvocationDescriptor { + return { + id, + service: 'goals', + namespace: 'goals', + method: 'create', + invocation: { kind: 'direct' }, + parameters: [{ + name: 'request', + wire: 'request', + source: 'json', + codec: { mode: 'src-json' }, + }], + result: { mode: 'src-json' }, + } +} + +function scopedInvocation(): InvocationDescriptor { + return { + ...invocation('@fixture/remote#goals/create-scoped'), + scope: { context: 'fixture', wire: 'agentId' }, + parameters: [{ + name: 'agent', + wire: 'agentId', + source: 'lookup', + lookup: 'fixture', + codec: { mode: 'src-json' }, + }, { + name: 'request', + wire: 'request', + source: 'json', + codec: { mode: 'src-json' }, + }], + } +} + describe('TypertRegistry', () => { it('registers and queries generated schemas separately from package reflection', async () => { const ctx = await makeCtx() @@ -69,7 +122,7 @@ describe('TypertRegistry', () => { const dispose = ctx.typert.register(toolsContribution()) expect(ctx.typert.getPackage('@deepseek-ai/dsh-tools')).toBeDefined() - dispose() + await dispose() expect(ctx.typert.get('@deepseek-ai/dsh-tools#ToolInput')).toBeUndefined() expect(ctx.typert.getPackage('@deepseek-ai/dsh-tools')).toBeUndefined() @@ -145,4 +198,133 @@ describe('TypertRegistry', () => { expect(projected).toMatchObject({ type: 'object', properties: { name: { type: 'string' } } }) expect(ctx.typert.toJSONSchema('@deepseek-ai/dsh-tools#ToolInput')).not.toBe(projected) }) + + it('registers local invocations atomically with generated reflection', async () => { + const ctx = await makeCtx() + const descriptor = invocation() + const contribution = { ...toolsContribution(), invocations: [descriptor] } + const changes: string[] = [] + ctx.typert.local.subscribe((change) => { changes.push(`${change.kind}:${change.key}`) }) + + expect(ctx.typert.local.hasSeen('goals/create')).toBe(false) + const dispose = ctx.typert.register(contribution) + + expect(typertEndpoint(descriptor)).toBe('goals/create') + expect(ctx.typert.local.get('goals/create')).toBe(descriptor) + expect(ctx.typert.local.hasSeen('goals/create')).toBe(true) + expect(ctx.typert.local.list()).toEqual([descriptor]) + expect(changes).toEqual(['local:goals/create']) + + await dispose() + expect(ctx.typert.local.list()).toEqual([]) + expect(ctx.typert.local.hasSeen('goals/create')).toBe(true) + expect(ctx.typert.getPackage('@deepseek-ai/dsh-tools')).toBeUndefined() + expect(changes).toEqual(['local:goals/create', 'local:goals/create']) + }) + + it('mounts Remote contributions in the calling fiber and withdraws them exactly', async () => { + const ctx = await makeCtx() + const descriptor = invocation() + const contribution: TypeRTRemoteContribution = { + package: '@fixture/remote', + descriptors: [descriptor], + } + const changes: string[] = [] + ctx.typert.remotes.subscribe((change) => { changes.push(`${change.kind}:${change.key}`) }) + const fiber = ctx.plugin(Object.assign( + (child: Context) => { child.typert.remotes.register(contribution) }, + { inject: ['typert'] }, + )) + await fiber + + expect(ctx.typert.remotes.get('goals/create')).toBe(descriptor) + expect(() => ctx.typert.remotes.register(contribution)).toThrow('Remote package') + + await fiber.dispose() + expect(ctx.typert.remotes.list()).toEqual([]) + expect(changes).toEqual(['remote:goals/create', 'remote:goals/create']) + }) + + it('accepts only a direct scope selecting its unique lookup parameter', async () => { + const ctx = await makeCtx() + const descriptor = scopedInvocation() + const dispose = ctx.typert.remotes.register({ package: '@fixture/scoped', descriptors: [descriptor] }) + expect(ctx.typert.remotes.get('goals/create')).toBe(descriptor) + await dispose() + + const cases: readonly [InvocationDescriptor, string][] = [ + [{ + ...descriptor, + invocation: { + kind: 'context', + context: 'fixture', + wire: 'scopeId', + codec: { mode: 'src-json' }, + }, + }, 'Context receiver cannot declare a direct scope projection'], + [{ ...descriptor, scope: { context: 'fixture', wire: 'missingId' } }, 'must select its only lookup parameter'], + [{ + ...descriptor, + parameters: [...descriptor.parameters, { + name: 'other', + wire: 'otherId', + source: 'lookup', + lookup: 'fixture', + codec: { mode: 'src-json' }, + }], + }, 'must select its only lookup parameter'], + [{ ...descriptor, scope: { context: 'other', wire: 'agentId' } }, 'must select its only lookup parameter'], + ] + for (const [index, [candidate, message]] of cases.entries()) { + expect(() => ctx.typert.remotes.register({ + package: `@fixture/rejected-${String(index)}`, + descriptors: [candidate], + })).toThrow(message) + } + expect(ctx.typert.remotes.list()).toEqual([]) + }) + + it('registers lookup and Context providers without domain branches', async () => { + const ctx = await makeCtx() + const object = { id: 'agent-1' } + const scoped = ctx.extend() + const disposeLookup = ctx.typert.lookups.register('fixture', { + parameter: 'agent', + wire: 'agentId', + hostTypeSymbol: '@fixture/agent#Agent', + wireTypeSymbol: '@fixture/session#SessionId', + resolve: id => id === object.id ? object : undefined, + }) + const disposeHost = ctx.typert.contexts.registerHost('registryFixture', { + wire: 'agentId', + wireTypeSymbol: '@fixture/session#SessionId', + resolve: id => id === object.id ? scoped : undefined, + }) + const disposeClient = ctx.typert.contexts.registerClient('registryFixture', { + identity: candidate => candidate === scoped ? object.id : undefined, + }) + + expect(ctx.typert.lookups.get('fixture')?.resolve('agent-1')).toBe(object) + expect(ctx.typert.contexts.getHost('registryFixture')?.resolve('agent-1')).toBe(scoped) + expect(ctx.typert.contexts.getClient('registryFixture')?.identity(scoped)).toBe('agent-1') + + await Promise.all([disposeClient(), disposeHost(), disposeLookup()]) + expect(ctx.typert.lookups.keys()).toEqual([]) + expect(ctx.typert.contexts.getHost('registryFixture')).toBeUndefined() + expect(ctx.typert.contexts.getClient('registryFixture')).toBeUndefined() + }) + + it('contains change-listener failures and still notifies later listeners', async () => { + const ctx = await makeCtx() + const warnings: unknown[] = [] + ctx.logger.warn = ((message: unknown) => { warnings.push(message) }) as typeof ctx.logger.warn + let observed = 0 + ctx.typert.remotes.subscribe(() => { throw new Error('observer failed') }) + ctx.typert.remotes.subscribe(() => { observed += 1 }) + + ctx.typert.remotes.register({ package: '@fixture/remote', descriptors: [invocation()] }) + + expect(observed).toBe(1) + expect(warnings.map(String)).toContain('Error: observer failed') + }) }) diff --git a/packages/typert/registry/tsconfig.json b/packages/typert/registry/tsconfig.json index 9966c8ca8a..311dfa4b6d 100644 --- a/packages/typert/registry/tsconfig.json +++ b/packages/typert/registry/tsconfig.json @@ -16,6 +16,9 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../type-meta" } ] } diff --git a/packages/typert/registry/tsdown.config.ts b/packages/typert/registry/tsdown.config.ts index 144513225b..e494104c4d 100644 --- a/packages/typert/registry/tsdown.config.ts +++ b/packages/typert/registry/tsdown.config.ts @@ -1,25 +1,3 @@ -import { defineConfig } from 'tsdown' +import { clientBundle } from '../../client/tsdown.client.ts' -/** Build the registry and its invariant companion as independent bundles. */ -export default defineConfig([ - { - entry: ['lib/types/index.js'], - outDir: 'lib', - format: ['esm'], - platform: 'node', - target: 'es2024', - fixedExtension: false, - dts: false, - clean: false, - }, - { - entry: ['lib/types/invariant.js'], - outDir: 'lib', - format: ['esm'], - platform: 'node', - target: 'es2024', - fixedExtension: false, - dts: false, - clean: false, - }, -]) +export default clientBundle('@deepseek-ai/dsh-typert-registry', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/typert/type-meta/README.i18n.yaml b/packages/typert/type-meta/README.i18n.yaml new file mode 100644 index 0000000000..90d93152b7 --- /dev/null +++ b/packages/typert/type-meta/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/typert/type-meta/README.md +README.md: 9dd8dadd07b219c7471c8851262958d4d9e96a43 +README.zh.md: 5716f56d988c6d2dd9cd237346c3b02ec9ae7c4e diff --git a/packages/typert/type-meta/README.md b/packages/typert/type-meta/README.md new file mode 100644 index 0000000000..9dd8dadd07 --- /dev/null +++ b/packages/typert/type-meta/README.md @@ -0,0 +1,33 @@ +# @deepseek-ai/dsh-type-meta + +English | [中文](README.zh.md) + +Compiler-independent declarations shared by business packages, generated TypeRT artifacts, the Host Gateway, and Client API. This package owns Remote decorators, the explicit Service binding, merge-extensible protocol maps, invocation descriptors, codecs, and provider contracts; it does not run TypeScript analysis or provide a Cordis service. + +## Remote declarations + +- `@Remote` marks a public instance method for direct invocation on its registered Cordis Service. +- `@RemoteContext(key)` marks a method whose receiver is selected from a merge-declared scoped Context kind. +- `bindTypeRTGateway(this, serviceKey, options?)` creates the visible, frozen binding between a Service instance, its exact Cordis key, and its wire namespace. +- `remoteMethods(service)` returns a detached declaration-order snapshot used by the Gateway's SRC fallback. + +Decorator initializers retain markers in a module-private `WeakMap` keyed by the Service prototype. They do not add constructor symbols, prototype properties, parameter metadata, or runtime reflection fields. The Service opts in explicitly through its `typertGateway` binding field. + +## TypeRT protocol + +Business packages extend `TypeRTLookupMap` and `TypeRTContextMap` to associate Host objects or scoped Contexts with their wire identities. Generated artifacts extend `TypeRTRemoteMap`, `TypeRTRemoteContextMap`, and `TypeRTRemoteNamespaceMap` so Client imports expose only selected Remote methods. `InvocationDescriptor` is the shared runtime form consumed by the registry, Gateway, and Client API. + +Lookup and Context packages own both sides of their contract: declaration merging supplies the static association, while runtime providers register identity resolution with `ctx.typert`. Strict codecs carry generated schemas; `src-json` codecs identify the weaker source-launch path. + +## Model Experience + +None, as this protocol package declares application reflection and registers no model surface. + +#### KV Cache effect + +No direct effect. + +## Known Limitations and Deferred Work + +- Decorator markers contain only the method name and direct or Context invocation mode. Parameter, result, lookup, and schema reflection require the TypeRT build pipeline. +- Remote decorators accept only public, non-static instance methods with string names. SRC execution cannot represent overloaded, destructured, defaulted, or rest-parameter signatures. diff --git a/packages/typert/type-meta/README.zh.md b/packages/typert/type-meta/README.zh.md new file mode 100644 index 0000000000..5716f56d98 --- /dev/null +++ b/packages/typert/type-meta/README.zh.md @@ -0,0 +1,33 @@ +# @deepseek-ai/dsh-type-meta + +[English](README.md) | 中文 + +该包提供不依赖编译器的声明,由业务包、生成的 TypeRT 产物、Host Gateway 和 Client API 共享。它负责 Remote 装饰器、显式服务绑定、可通过声明合并扩展的协议映射、调用描述符、编解码器和提供方契约;它不执行 TypeScript 分析,也不提供 Cordis 服务。 + +## Remote 声明 + +- `@Remote` 将公开实例方法标记为可在其注册的 Cordis 服务上直接调用。 +- `@RemoteContext(key)` 标记接收者选自合并声明的作用域 Context 类型的方法。 +- `bindTypeRTGateway(this, serviceKey, options?)` 在服务实例、其准确的 Cordis key 与协议命名空间之间创建可见且冻结的绑定。 +- `remoteMethods(service)` 返回按声明顺序排列、与内部状态分离的快照,供 Gateway 的 SRC 回退路径使用。 + +装饰器初始化器将标记保存在以服务 prototype 为键的模块私有 `WeakMap` 中。它们不会在构造函数上添加 symbol,也不会添加 prototype 属性、参数元数据或运行时反射字段。服务通过自身的 `typertGateway` 绑定字段显式接入。 + +## TypeRT 协议 + +业务包扩展 `TypeRTLookupMap` 和 `TypeRTContextMap`,以关联 Host 对象或作用域 Context 与其协议身份。生成的产物扩展 `TypeRTRemoteMap`、`TypeRTRemoteContextMap` 和 `TypeRTRemoteNamespaceMap`,使 Client 导入后仅暴露选定的 Remote 方法。`InvocationDescriptor` 是供注册表、Gateway 和 Client API 使用的共享运行时形式。 + +查找包与 Context 包同时负责其契约的两侧:声明合并提供静态关联,运行时提供方则向 `ctx.typert` 注册身份解析。严格编解码器携带生成的 schema;`src-json` 编解码器标识约束更弱的源码启动路径。 + +## 模型体验 + +无,因为该协议包声明应用反射,不注册任何模型接口。 + +#### KV Cache 影响 + +无直接影响。 + +## 已知限制与延期工作 + +- 装饰器标记仅包含方法名,以及直接调用或 Context 调用模式。参数、结果、查找和 schema 反射需要 TypeRT 构建流水线。 +- Remote 装饰器只接受具有字符串名称的公开、非静态实例方法。SRC 执行无法表示重载签名,以及包含解构参数、默认参数或剩余参数的方法签名。 diff --git a/packages/typert/type-meta/package.json b/packages/typert/type-meta/package.json new file mode 100644 index 0000000000..2ffcd6c5ed --- /dev/null +++ b/packages/typert/type-meta/package.json @@ -0,0 +1,42 @@ +{ + "name": "@deepseek-ai/dsh-type-meta", + "description": "Compiler-independent Remote metadata and TypeRT provider protocols", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/typert/type-meta/src/index.ts b/packages/typert/type-meta/src/index.ts new file mode 100644 index 0000000000..1e79bb2e55 --- /dev/null +++ b/packages/typert/type-meta/src/index.ts @@ -0,0 +1,223 @@ +/** + * Remote decorators and explicit Gateway bindings backed only by private + * module state. Strict reflection remains a TypeRT compiler responsibility. + * @module @deepseek-ai/dsh-type-meta + */ + +import type { TypeRTContextMap } from './types.ts' + +export type { + InvocationDescriptor, + InvocationParameterDescriptor, + InvocationSourceLocation, + TypeRTClientContextBinder, + TypeRTCodec, + TypeRTContext, + TypeRTContextMap, + TypeRTContextRegistry, + TypeRTContextWire, + TypeRTDisposer, + TypeRTHostContextProvider, + TypeRTLocalRegistry, + TypeRTLookup, + TypeRTLookupHost, + TypeRTLookupMap, + TypeRTLookupProvider, + TypeRTLookupRegistry, + TypeRTLookupWire, + TypeRTRemoteContextApi, + TypeRTRemoteContextMap, + TypeRTRemoteContextNamespace, + TypeRTRemoteContribution, + TypeRTRemoteMap, + TypeRTRemoteNamespace, + TypeRTRemoteNamespaceMap, + TypeRTRemoteRegistry, + TypeRTRegistryChange, + TypeRTRegistryListener, + TypeRTSchema, + TypeRTService, +} from './types.ts' + +/** Options for an explicit Service-to-Gateway binding. */ +export interface TypeRTGatewayBindingOptions { + /** Wire namespace; defaults to the Cordis service key. */ + readonly namespace?: string +} + +/** Visible declaration that one Service participates in TypeRT Gateway export. */ +export interface TypeRTGatewayBinding { + readonly service: Service + readonly serviceKey: string + readonly namespace: string +} + +/** Invocation mode recorded by a Remote method decorator. */ +export type RemoteInvocationMarker = + | { readonly kind: 'direct' } + | { readonly kind: 'context'; readonly context: string } + +/** One decorator marker discovered for a live Service instance. */ +export interface RemoteMethodMarker { + /** Public instance method carrying the implementation. */ + readonly method: string + /** Endpoint method when it differs from the implementation member. */ + readonly exportName?: string + readonly invocation: RemoteInvocationMarker +} + +type RemoteMethodDecorator = ( + method: (this: This, ...args: Args) => Result, + context: ClassMethodDecoratorContext Result>, +) => void + +interface RemoteInitializerContext { + readonly private: boolean + readonly static: boolean + readonly name: string | symbol + addInitializer(initializer: (this: This) => void): void +} + +interface StoredRemoteMethodMarker { + readonly exportName?: string + readonly invocation: RemoteInvocationMarker +} + +const markers = new WeakMap>() + +/** + * Bind one visible Service field to a Cordis key and Remote namespace. + * @param service - owning Service instance, normally `this`. + * @param serviceKey - exact Cordis service key. + * @param options - optional distinct wire namespace. + * @returns a frozen, inspectable binding with no compiler-injected metadata. + */ +export function bindTypeRTGateway( + service: Service, + serviceKey: string, + options: TypeRTGatewayBindingOptions = {}, +): TypeRTGatewayBinding { + validateName('service key', serviceKey) + const namespace = options.namespace ?? serviceKey + validateName('namespace', namespace) + return Object.freeze({ service, serviceKey, namespace }) +} + +/** + * Mark one public instance method as a direct Remote invocation. + * @param _method - decorated method; retained only by the class itself. + * @param context - standard decorator context used to schedule private marking. + */ +export function Remote( + _method: (this: This, ...args: Args) => Result, + context: ClassMethodDecoratorContext Result>, +): void +/** + * Mark one public instance method under a distinct exported method name. + * @param exportName - Remote endpoint method, without a namespace or slash. + * @returns a standard method decorator. + */ +export function Remote(exportName: string): RemoteMethodDecorator +export function Remote( + methodOrExportName: string | ((this: This, ...args: Args) => Result), + context?: ClassMethodDecoratorContext Result>, +): void | RemoteMethodDecorator { + if (typeof methodOrExportName === 'string') { + validateName('Remote export name', methodOrExportName) + return function ( + _method: (this: DecoratorThis, ...args: DecoratorArgs) => DecoratorResult, + decoratorContext: ClassMethodDecoratorContext< + DecoratorThis, + (this: DecoratorThis, ...args: DecoratorArgs) => DecoratorResult + >, + ): void { + addMarkerInitializer(decoratorContext, { kind: 'direct' }, methodOrExportName) + } + } + if (context === undefined) throw new TypeError('type-meta: Remote decorator context is missing') + addMarkerInitializer(context, { kind: 'direct' }) +} + +/** + * Create a decorator for a method resolved from one scoped Remote Context. + * @param key - merge-declared Context key. + * @param exportName - optional Remote export name; defaults to the method name. + * @returns a standard method decorator that records only private module state. + */ +export function RemoteContext( + key: Extract, + exportName?: string, +): RemoteMethodDecorator { + validateName('Context key', key) + if (exportName !== undefined) validateName('Remote export name', exportName) + return function ( + _method: (this: This, ...args: Args) => Result, + context: ClassMethodDecoratorContext Result>, + ): void { + addMarkerInitializer(context, { kind: 'context', context: key }, exportName) + } +} + +/** + * Read Remote markers attached to a live Service by decorator initializers. + * The returned snapshot cannot mutate the private marker table. + * @param service - live Service instance. + * @returns markers in class declaration order. + */ +export function remoteMethods(service: object): readonly RemoteMethodMarker[] { + const prototype = Object.getPrototypeOf(service) as object | null + if (prototype === null) return [] + return [...(markers.get(prototype) ?? [])].map(([method, marker]) => ({ method, ...marker })) +} + +function addMarkerInitializer( + context: RemoteInitializerContext, + invocation: RemoteInvocationMarker, + exportName?: string, +): void { + if (context.private || context.static || typeof context.name !== 'string') { + throw new TypeError('type-meta: Remote decorators require a public instance method with a string name') + } + const method = context.name + context.addInitializer(function (this: This) { + const prototype = Object.getPrototypeOf(this) as object | null + if (prototype === null) { + throw new TypeError(`type-meta: cannot mark Remote method "${method}" on an object without a prototype`) + } + mark(prototype, method, invocation, exportName) + }) +} + +function mark( + prototype: object, + method: string, + invocation: RemoteInvocationMarker, + exportName?: string, +): void { + let table = markers.get(prototype) + if (table === undefined) { + table = new Map() + markers.set(prototype, table) + } + const marker: StoredRemoteMethodMarker = { + ...(exportName === undefined || exportName === method ? {} : { exportName }), + invocation: Object.freeze(invocation), + } + const current = table.get(method) + if (current !== undefined) { + if (current.exportName === marker.exportName && sameInvocation(current.invocation, invocation)) return + throw new Error(`type-meta: Remote method "${method}" has conflicting invocation markers`) + } + table.set(method, Object.freeze(marker)) +} + +function sameInvocation(left: RemoteInvocationMarker, right: RemoteInvocationMarker): boolean { + return left.kind === right.kind + && (left.kind === 'direct' || (right.kind === 'context' && left.context === right.context)) +} + +function validateName(subject: string, value: string): void { + if (value.length === 0 || value.includes('/')) { + throw new TypeError(`type-meta: ${subject} must be nonempty and must not contain "/"`) + } +} diff --git a/packages/typert/type-meta/src/invariant.ts b/packages/typert/type-meta/src/invariant.ts new file mode 100644 index 0000000000..22dc290a1e --- /dev/null +++ b/packages/typert/type-meta/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-type-meta`. + * @module @deepseek-ai/dsh-type-meta/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-type-meta' + +/** Cordis companion plugin name. */ +export const name = 'type-meta-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: decorators retain private immutable declarations and + * bindings are frozen values with no independent event stream to cross-check. + */ +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/typert/type-meta/src/types.ts b/packages/typert/type-meta/src/types.ts new file mode 100644 index 0000000000..87ab091075 --- /dev/null +++ b/packages/typert/type-meta/src/types.ts @@ -0,0 +1,358 @@ +/** + * Compiler-independent TypeRT protocol shared by business packages, generated + * Remote artifacts, the Host Gateway, and Client API implementations. + * @module @deepseek-ai/dsh-type-meta/types + */ + +import type { Context } from 'cordis' + +declare const LOOKUP_HOST: unique symbol +declare const LOOKUP_WIRE: unique symbol +declare const CONTEXT_WIRE: unique symbol + +/** Type-level association between a Host object and its wire identity. */ +export interface TypeRTLookup { + readonly [LOOKUP_HOST]: Host + readonly [LOOKUP_WIRE]: Wire +} + +/** Extract the Host object associated with one lookup declaration. */ +export type TypeRTLookupHost = Lookup extends TypeRTLookup ? Host : never + +/** Extract the wire identity associated with one lookup declaration. */ +export type TypeRTLookupWire = Lookup extends TypeRTLookup ? Wire : never + +/** Type-level association between a scoped Context kind and its wire identity. */ +export interface TypeRTContext { + readonly [CONTEXT_WIRE]: Wire +} + +/** Extract the wire identity associated with one scoped Context declaration. */ +export type TypeRTContextWire = ContextType extends TypeRTContext ? Wire : never + +/** Merge-extensible Host object lookup declarations. */ +export interface TypeRTLookupMap {} + +/** Merge-extensible scoped Context declarations. */ +export interface TypeRTContextMap {} + +/** Merge-extensible direct Remote method signatures generated for consumers. */ +export interface TypeRTRemoteMap {} + +/** Merge-extensible scoped Remote method signatures generated for consumers. */ +export interface TypeRTRemoteContextMap {} + +/** + * Resolve one direct Remote namespace from the generated flat endpoint map. + * @template Namespace - wire namespace before the endpoint slash. + */ +export type TypeRTRemoteNamespace = { + [Endpoint in keyof TypeRTRemoteMap as Endpoint extends `${Namespace}/${infer Method}` + ? Method + : never]: TypeRTRemoteMap[Endpoint] +} + +/** + * Resolve one scoped Remote namespace across every generated Context kind. + * The calling Cordis Context supplies the concrete identity at runtime. + * @template Namespace - wire namespace between the Context prefix and method. + */ +export type TypeRTRemoteContextNamespace< + Namespace extends string, + ContextKey extends string = string, +> = { + [Endpoint in keyof TypeRTRemoteContextMap as Endpoint extends `${ContextKey}:${Namespace}/${infer Method}` + ? Method + : never]: TypeRTRemoteContextMap[Endpoint] +} + +type TypeRTRemoteContextNamespaceKey< + ContextKey extends string, + Endpoint = keyof TypeRTRemoteContextMap, +> = Endpoint extends `${ContextKey}:${infer Namespace}/${string}` ? Namespace : never + +/** Generated scoped Remote namespaces available to one Context kind. */ +export type TypeRTRemoteContextApi = { + [Namespace in TypeRTRemoteContextNamespaceKey]: + TypeRTRemoteContextNamespace +} + +/** Merge-extensible direct namespace surface generated for Client API services. */ +export interface TypeRTRemoteNamespaceMap {} + +/** Awaitable disposer returned by Cordis-owned TypeRT registrations. */ +export type TypeRTDisposer = () => Promise + +type StringKeyOf = Extract + +/** Minimal runtime-schema capability carried by strict generated codecs. */ +export interface TypeRTSchema { + /** + * Parse and validate one boundary value. + * @param value - untrusted boundary value. + * @returns the validated value. + */ + parse(value: unknown): Output +} + +/** Codec attached to one invocation parameter or result. */ +export type TypeRTCodec = + | { + readonly mode: 'strict' + readonly typeSymbol: string + readonly schema: TypeRTSchema + } + | { + readonly mode: 'src-json' + } + +/** One ordered business parameter in a Remote invocation. */ +export interface InvocationParameterDescriptor { + /** Source-level parameter name. */ + readonly name: string + /** Required key in the wire `args` object. */ + readonly wire: string + /** Whether the value is JSON or requires a registered Host lookup. */ + readonly source: 'json' | 'lookup' + /** Lookup key when `source` is `lookup`. */ + readonly lookup?: string + /** Boundary codec for the wire representation. */ + readonly codec: TypeRTCodec +} + +/** Source position retained for diagnostics from generated definitions. */ +export interface InvocationSourceLocation { + readonly file: string + readonly line: number + readonly column: number +} + +/** Carrier-independent description of one exported method invocation. */ +export interface InvocationDescriptor { + /** Globally stable generated identity. */ + readonly id: string + /** Cordis service key owning the method. */ + readonly service: string + /** Wire namespace, defaulting to the service key. */ + readonly namespace: string + /** Public instance method name. */ + readonly method: string + /** Service member invoked when the exported method name is an alias. */ + readonly implementation?: string + /** Receiver selection mode. */ + readonly invocation: + | { readonly kind: 'direct' } + | { + readonly kind: 'context' + readonly context: string + readonly wire: string + readonly codec: TypeRTCodec + } + /** Optional consuming-Context projection for one direct lookup parameter. */ + readonly scope?: { + /** Context kind whose Client binder supplies the identity. */ + readonly context: string + /** Lookup parameter wire field replaced by the Context identity. */ + readonly wire: string + } + /** Ordered business parameters. */ + readonly parameters: readonly InvocationParameterDescriptor[] + /** Codec for the resolved method result. */ + readonly result: TypeRTCodec + /** Source declaration used only for diagnostics. */ + readonly sourceLocation?: InvocationSourceLocation +} + +/** Generated Host contract selected explicitly by a Client assembly. */ +export interface TypeRTRemoteContribution { + /** npm package that owns the Remote methods. */ + readonly package: string + /** Consumer-side invocation descriptors generated from that package. */ + readonly descriptors: readonly InvocationDescriptor[] +} + +/** Runtime resolver for one declared Host object lookup. */ +export interface TypeRTLookupProvider { + /** Source parameter name recognized by the SRC weak parser. */ + readonly parameter: string + /** Wire field replacing the Host object parameter. */ + readonly wire: string + /** Canonical Host type symbol used by strict generation. */ + readonly hostTypeSymbol: string + /** Canonical wire type symbol used by strict generation. */ + readonly wireTypeSymbol: string + /** + * Resolve a wire identity to the current live Host object. + * @param id - validated wire identity. + * @returns the live object, or `undefined` when it is unavailable. + */ + resolve(id: Wire): Host | undefined +} + +/** Host resolver for one scoped Remote Context kind. */ +export interface TypeRTHostContextProvider { + /** Wire field carrying the Context identity. */ + readonly wire: string + /** Canonical wire type symbol used by strict generation. */ + readonly wireTypeSymbol: string + /** + * Resolve a wire identity to its live scoped Context. + * @param id - validated wire identity. + * @returns the scoped Context, or `undefined` when unavailable. + */ + resolve(id: Wire): Context | undefined +} + +/** Client resolver for the identity carried by the calling scoped Context. */ +export interface TypeRTClientContextBinder { + /** + * Read the Remote identity represented by a calling Context. + * @param ctx - Context rebound by the Cordis service tracker. + * @returns the wire identity, or `undefined` when the Context has the wrong scope. + */ + identity(ctx: Context): Wire | undefined +} + +/** Notification emitted after a TypeRT runtime registry changes. */ +export interface TypeRTRegistryChange { + readonly kind: 'local' | 'remote' | 'lookup' | 'host-context' | 'client-context' + readonly key: string +} + +/** Listener for one TypeRT runtime registry. */ +export type TypeRTRegistryListener = (change: TypeRTRegistryChange) => void + +/** Current-environment invocation definitions. */ +export interface TypeRTLocalRegistry { + /** + * Look up one invocation by `/`. + * @param endpoint - canonical endpoint. + * @returns the live descriptor, or `undefined` when absent. + */ + get(endpoint: string): InvocationDescriptor | undefined + /** + * Report whether a strict definition has existed during this TypeRT Service lifetime. + * @param endpoint - canonical endpoint. + * @returns `true` after the endpoint has been registered at least once, even if withdrawn. + */ + hasSeen(endpoint: string): boolean + /** @returns a registration-order snapshot of local descriptors. */ + list(): readonly InvocationDescriptor[] + /** + * Observe later local-definition changes. + * @param listener - synchronous contained observer. + * @returns disposer for this subscription. + */ + subscribe(listener: TypeRTRegistryListener): TypeRTDisposer +} + +/** Consumer-selected Remote contribution registry. */ +export interface TypeRTRemoteRegistry { + /** + * Register one generated contribution for the calling Cordis fiber. + * @param contribution - generated Remote descriptors. + * @returns disposer withdrawing the exact contribution. + */ + register(contribution: TypeRTRemoteContribution): TypeRTDisposer + /** + * Look up one Remote descriptor by endpoint. + * @param endpoint - canonical endpoint. + * @returns the descriptor, or `undefined` when unmounted. + */ + get(endpoint: string): InvocationDescriptor | undefined + /** @returns a registration-order snapshot of Remote descriptors. */ + list(): readonly InvocationDescriptor[] + /** + * Observe later Remote contribution changes. + * @param listener - synchronous contained observer. + * @returns disposer for this subscription. + */ + subscribe(listener: TypeRTRegistryListener): TypeRTDisposer +} + +/** Runtime registry for Host object lookup providers. */ +export interface TypeRTLookupRegistry { + /** + * Register one provider under its merge-declared key. + * @param key - lookup key. + * @param provider - owning package's live resolver. + * @returns disposer withdrawing the exact provider. + */ + register>( + key: K, + provider: TypeRTLookupProvider< + TypeRTLookupHost, + TypeRTLookupWire + >, + ): TypeRTDisposer + /** + * Look up one provider by runtime key. + * @param key - descriptor lookup key. + * @returns the live provider, or `undefined` when absent. + */ + get(key: string): TypeRTLookupProvider | undefined + /** @returns a snapshot of registered provider keys. */ + keys(): readonly string[] + /** + * Observe later lookup changes. + * @param listener - synchronous contained observer. + * @returns disposer for this subscription. + */ + subscribe(listener: TypeRTRegistryListener): TypeRTDisposer +} + +/** Runtime registry for Host Context resolvers and Client Context binders. */ +export interface TypeRTContextRegistry { + /** + * Register a Host Context resolver. + * @param key - merge-declared Context key. + * @param provider - owning package's Host resolver. + * @returns disposer withdrawing the exact provider. + */ + registerHost>( + key: K, + provider: TypeRTHostContextProvider>, + ): TypeRTDisposer + /** + * Register a Client Context identity binder. + * @param key - merge-declared Context key. + * @param binder - Client scope identity resolver. + * @returns disposer withdrawing the exact binder. + */ + registerClient>( + key: K, + binder: TypeRTClientContextBinder>, + ): TypeRTDisposer + /** + * Look up a Host Context resolver. + * @param key - descriptor Context key. + * @returns the provider, or `undefined` when absent. + */ + getHost(key: string): TypeRTHostContextProvider | undefined + /** + * Look up a Client Context binder. + * @param key - descriptor Context key. + * @returns the binder, or `undefined` when absent. + */ + getClient(key: string): TypeRTClientContextBinder | undefined + /** + * Observe later Context provider changes. + * @param listener - synchronous contained observer. + * @returns disposer for this subscription. + */ + subscribe(listener: TypeRTRegistryListener): TypeRTDisposer +} + +/** Minimal TypeRT runtime consumed through dependency inversion. */ +export interface TypeRTService { + readonly local: TypeRTLocalRegistry + readonly remotes: TypeRTRemoteRegistry + readonly lookups: TypeRTLookupRegistry + readonly contexts: TypeRTContextRegistry +} + +declare module 'cordis' { + interface Context { + typert: TypeRTService + } +} diff --git a/packages/typert/type-meta/tests/fixtures/source-launch.ts b/packages/typert/type-meta/tests/fixtures/source-launch.ts new file mode 100644 index 0000000000..68f886dff1 --- /dev/null +++ b/packages/typert/type-meta/tests/fixtures/source-launch.ts @@ -0,0 +1,29 @@ +import { + bindTypeRTGateway, + Remote, + RemoteContext, + remoteMethods, +} from '@deepseek-ai/dsh-type-meta' + +class Goals { + readonly typertGateway = bindTypeRTGateway(this, 'goals') + + @Remote + create(value: string): string { + return value + } + + @RemoteContext('agent') + scoped(value: string): string { + return value + } +} + +const methods = remoteMethods(new Goals()) +const actual = JSON.stringify(methods) +const expected = JSON.stringify([ + { method: 'create', invocation: { kind: 'direct' } }, + { method: 'scoped', invocation: { kind: 'context', context: 'agent' } }, +]) +if (actual !== expected) throw new Error(`unexpected Remote declarations: ${actual}`) +process.stdout.write(actual) diff --git a/packages/typert/type-meta/tests/type-meta.spec.ts b/packages/typert/type-meta/tests/type-meta.spec.ts new file mode 100644 index 0000000000..1eab5a6ca3 --- /dev/null +++ b/packages/typert/type-meta/tests/type-meta.spec.ts @@ -0,0 +1,132 @@ +import { execFileSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { + bindTypeRTGateway, + Remote, + RemoteContext, + remoteMethods, + type TypeRTContext, +} from '@deepseek-ai/dsh-type-meta' + +declare module '@deepseek-ai/dsh-type-meta' { + interface TypeRTContextMap { + metaFixture: TypeRTContext + } +} + +describe('type-meta Remote declarations', () => { + it('executes standard decorator syntax through the Vitest source transform', () => { + class Goals { + readonly typertGateway = bindTypeRTGateway(this, 'goals') + + @Remote + create(value: string): string { + return value + } + + @RemoteContext('metaFixture') + scoped(value: string): string { + return value + } + } + + const goals = new Goals() + expect(remoteMethods(goals)).toEqual([ + { method: 'create', invocation: { kind: 'direct' } }, + { method: 'scoped', invocation: { kind: 'context', context: 'metaFixture' } }, + ]) + }) + + it('executes standard decorator syntax through the TSX source launcher', () => { + const fixture = fileURLToPath(new URL('./fixtures/source-launch.ts', import.meta.url)) + const output = execFileSync(process.execPath, ['--import', 'tsx/esm', fixture], { encoding: 'utf8' }) + expect(JSON.parse(output)).toEqual([ + { method: 'create', invocation: { kind: 'direct' } }, + { method: 'scoped', invocation: { kind: 'context', context: 'agent' } }, + ]) + }) + + it('keeps decorator markers in private module state', () => { + class Goals { + readonly typertGateway = bindTypeRTGateway(this, 'goals') + + create(agent: object, request: object): object { + return { agent, request } + } + + scoped(request: object): object { + return request + } + } + + const initializers: Array<(this: Goals) => void> = [] + Remote( + Reflect.get(Goals.prototype, 'create') as (this: Goals, ...args: unknown[]) => unknown, + methodContext('create', initializers), + ) + RemoteContext('metaFixture')( + Reflect.get(Goals.prototype, 'scoped') as (this: Goals, ...args: unknown[]) => unknown, + methodContext('scoped', initializers), + ) + + const goals = new Goals() + for (const initialize of initializers) initialize.call(goals) + expect(goals.typertGateway).toEqual({ service: goals, serviceKey: 'goals', namespace: 'goals' }) + expect(Object.isFrozen(goals.typertGateway)).toBe(true) + expect(remoteMethods(goals)).toEqual([ + { method: 'create', invocation: { kind: 'direct' } }, + { method: 'scoped', invocation: { kind: 'context', context: 'metaFixture' } }, + ]) + expect(Reflect.ownKeys(Goals)).toEqual(['length', 'name', 'prototype']) + expect(Reflect.ownKeys(Goals.prototype)).toEqual(['constructor', 'create', 'scoped']) + }) + + it('keeps markers idempotent across instances and returns detached snapshots', () => { + class Service { + run(value: string): string { + return value + } + } + + const initializers: Array<(this: Service) => void> = [] + Remote( + Reflect.get(Service.prototype, 'run') as (this: Service, ...args: unknown[]) => unknown, + methodContext('run', initializers), + ) + + const first = new Service() + const second = new Service() + for (const initialize of initializers) { + initialize.call(first) + initialize.call(second) + } + const snapshot = remoteMethods(first) + expect(remoteMethods(second)).toEqual(snapshot) + ;(snapshot as unknown as { method: string }[])[0]!.method = 'changed' + expect(remoteMethods(first)).toEqual([{ method: 'run', invocation: { kind: 'direct' } }]) + }) + + it('rejects ambiguous binding names', () => { + expect(() => bindTypeRTGateway({}, '')).toThrow('service key') + expect(() => bindTypeRTGateway({}, 'goals', { namespace: 'api/goals' })).toThrow('namespace') + }) +}) + +function methodContext( + name: string, + initializers: Array<(this: This) => void>, +): ClassMethodDecoratorContext unknown> { + return { + kind: 'method', + name, + static: false, + private: false, + metadata: {}, + access: { + has: object => name in object, + get: object => (object as Record)[name] as (this: This, ...args: unknown[]) => unknown, + }, + addInitializer: (initializer) => { initializers.push(initializer) }, + } +} diff --git a/packages/typert/type-meta/tsconfig.json b/packages/typert/type-meta/tsconfig.json new file mode 100644 index 0000000000..9966c8ca8a --- /dev/null +++ b/packages/typert/type-meta/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ea3070dfa..45e8ad1803 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -916,6 +916,9 @@ importers: '@deepseek-ai/dsh-goal-session': specifier: workspace:^ version: link:../../goal/goal-session + '@deepseek-ai/dsh-host-api-gateway': + specifier: workspace:^ + version: link:../../host/api-gateway '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1054,6 +1057,12 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools + '@deepseek-ai/dsh-typert-loader': + specifier: workspace:^ + version: link:../../typert/loader + '@deepseek-ai/dsh-typert-registry': + specifier: workspace:^ + version: link:../../typert/registry '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../../ui/user-approval @@ -2790,6 +2799,12 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../system-prompt + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../../typert/type-meta + '@deepseek-ai/dsh-typert-registry': + specifier: workspace:^ + version: link:../../typert/registry cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis @@ -2854,6 +2869,12 @@ importers: '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../scope + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../../typert/type-meta + '@deepseek-ai/dsh-typert-registry': + specifier: workspace:^ + version: link:../../typert/registry cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis @@ -3706,6 +3727,31 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/host/api-gateway: + dependencies: + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../../typert/type-meta + devDependencies: + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../../client/connection + '@deepseek-ai/dsh-host-webserver': + specifier: workspace:^ + version: link:../webserver + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-typert-registry': + specifier: workspace:^ + version: link:../../typert/registry + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + zod: + specifier: ^4.4.3 + version: 4.4.3 + packages/host/apiproxy: dependencies: '@deepseek-ai/dsh-agent': @@ -6096,6 +6142,9 @@ importers: packages/typert/generator: dependencies: + '@jridgewell/gen-mapping': + specifier: ^0.3.13 + version: 0.3.13 typescript: specifier: ^6.0.3 version: 6.0.3 @@ -6140,6 +6189,9 @@ importers: packages/typert/registry: dependencies: + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../type-meta zod: specifier: ^4.4.3 version: 4.4.3 @@ -6151,6 +6203,15 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/typert/type-meta: + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/ui/app-boot: dependencies: js-yaml: diff --git a/scripts/client-bundle-purity.spec.ts b/scripts/client-bundle-purity.spec.ts index 8b10822bca..fb47f8a9c8 100644 --- a/scripts/client-bundle-purity.spec.ts +++ b/scripts/client-bundle-purity.spec.ts @@ -59,6 +59,13 @@ describe('client bundle purity gate', () => { expect(resolveId('@deepseek-ai/dsh-brand')).toBeNull() }) + it('lets exact generated Remote contributions inline without admitting their package implementation', () => { + expect(resolveId('@deepseek-ai/dsh-goal/remote')).toBeNull() + expect(() => resolveId('@deepseek-ai/dsh-goal')).toThrow(/purity/) + expect(() => resolveId('@deepseek-ai/dsh-goal/client')).toThrow(/purity/) + expect(() => resolveId('@deepseek-ai/dsh-goal/remote/nested')).toThrow(/purity/) + }) + it('throws on any other @deepseek-ai leak', () => { expect(() => resolveId('@deepseek-ai/dsh-agent')).toThrow(/purity/) expect(() => resolveId('@deepseek-ai/dsh-client-web')).toThrow(/purity/) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index bcf90d1e82..84013225f7 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -276,6 +276,7 @@ export const TYPE_LINK_EXEMPTIONS: Readonly> = { TypertPackageRecord: 'registry package record is owned by packages/typert/registry/README.md', TypertSchemaFilter: 'registry schema query filter is owned by packages/typert/registry/README.md', TypertSchemaRecord: 'registry schema record is owned by packages/typert/registry/README.md', + TypeRTDisposer: 'TypeRT lifecycle contract is owned by packages/typert/type-meta/README.md', 'z.core.JSONSchema.BaseSchema': 'zod projection output is owned by the zod v4 API', 'z.core.ToJSONSchemaParams': 'zod projection parameters are owned by the zod v4 API', InvariantInstaller: 'service-local contribution contract is owned by packages/support/invariants/README.md', @@ -287,6 +288,7 @@ export const TYPE_LINK_EXEMPTIONS: Readonly> = { ThemeTokens: 'service-local token dictionary is owned by packages/client/ui-theme/src/index.ts', Translate: 'service-local bound translator is owned by packages/client/i18n/src/index.ts', InvariantRegistration: 'service-local lifecycle handle is owned by packages/support/invariants/README.md', + InvokeRemoteRequest: 'gateway invocation contract is owned by packages/host/api-gateway/README.md', PresetOption: 'deployment menu metadata is owned by packages/ui/permission/README.md', PresetSpec: 'deployment preset composition is owned by packages/ui/permission/README.md', KnobState: 'projection unit state shape is owned by packages/ui/permission/README.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 1c7e2c3a0c..151aadb278 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -140,8 +140,15 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'typert-registry', title: 'Runtime type registry', mode: 'core', - consumers: ['typert-loader'], - note: 'Plugins register live zod contributions directly or through dsh-typert-loader; runtime consumers query schemas and reflection metadata at their own edges.', + consumers: ['typert-loader', 'api-gateway'], + note: '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.', + }, + { + key: 'typertGateway', + pkg: 'api-gateway', + title: 'TypeRT Host invocation gateway', + mode: 'core', + note: 'Associates generated Remote descriptors with live Cordis services, resolves registered identities, and exposes unary calls through the shared Connection RPC carrier.', }, { key: 'sessionPersistence', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 53491b89fb..7e81b30e07 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -125,6 +125,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/support/loader-smoke': { kind: 'none', reason: 'The test harness observes child-process streams without changing live requests.' }, 'packages/support/llm-mock-server': { kind: 'none', reason: 'The test server substitutes provider wire behavior without invoking a real model.' }, 'packages/support/llm-replay': { kind: 'none', reason: 'The keyless adapter invokes no provider model.' }, + 'packages/host/api-gateway': { kind: 'none', reason: 'Remote dispatch infrastructure; invoked business methods own any model-visible effect.' }, + 'packages/typert/type-meta': { kind: 'none', reason: 'Compiler-independent Remote protocol declarations; registers no model surface.' }, 'packages/typert/generator': { kind: 'none', reason: 'The build-time generator runs outside any agent runtime and touches no model request.' }, 'packages/tasks/tasks': { kind: 'indirect', reason: 'Producer and control-surface plugins own all model rendering over the task registry.' }, 'packages/tasks/tasks-local': { kind: 'indirect', reason: 'The registry backend delegates model rendering to producer plugins and dsh-tool-tasks.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 001158afe0..ce4fca35f9 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -40,6 +40,13 @@ "@cordisjs/plugin-logger-console": ["./vendor/logger-console/src"], "@deepseek-ai/dsh-invariants": ["./packages/support/invariants/src/index.ts"], "@deepseek-ai/dsh-typert-registry": ["./packages/typert/registry/src/index.ts"], + "@deepseek-ai/dsh-typert-registry/client": ["./packages/typert/registry/src/client/index.ts"], + "@deepseek-ai/dsh-host-api-gateway": ["./packages/host/api-gateway/src/index.ts"], + "@deepseek-ai/dsh-host-api-gateway/client": ["./packages/host/api-gateway/src/client/index.ts"], + "@deepseek-ai/dsh-host-api-gateway/invariant": ["./packages/host/api-gateway/src/invariant.ts"], + "@deepseek-ai/dsh-host-api-gateway/types": ["./packages/host/api-gateway/src/types.ts"], + "@deepseek-ai/dsh-type-meta": ["./packages/typert/type-meta/src/index.ts"], + "@deepseek-ai/dsh-type-meta/types": ["./packages/typert/type-meta/src/types.ts"], "@deepseek-ai/dsh-typert-loader": ["./packages/typert/loader/src/index.ts"], "@deepseek-ai/dsh-session/invariant": ["./packages/core/session/src/invariant.ts"], "@deepseek-ai/dsh-typert-registry/types": ["./packages/typert/registry/src/types.ts"], @@ -68,7 +75,6 @@ "@deepseek-ai/dsh-tool-subagent-control/list-agents": ["./packages/subagent/tool-subagent-control/src/list-agents.ts"], "@deepseek-ai/dsh-user-approval/types": ["./packages/ui/user-approval/src/types.ts"], "@deepseek-ai/dsh-user-interaction/types": ["./packages/ui/user-interaction/src/types.ts"], - "@deepseek-ai/dsh-agent/brand": ["./packages/core/agent/src/brand.ts"], "@deepseek-ai/dsh-agent/invariant": ["./packages/core/agent/src/invariant.ts"], "@deepseek-ai/dsh-scope/invariant": ["./packages/core/scope/src/invariant.ts"], "@deepseek-ai/dsh-agent-loop/invariant": ["./packages/core/agent-loop/src/invariant.ts"], @@ -145,6 +151,8 @@ "@deepseek-ai/dsh-client-schema-form/invariant": ["./packages/client/schema-form/src/invariant.ts"], "@deepseek-ai/dsh-client-web-react": ["./packages/client/web-react/src"], "@deepseek-ai/dsh-client-connection": ["./packages/client/connection/src"], + "@deepseek-ai/dsh-client-remotes": ["./packages/client/remotes/src"], + "@deepseek-ai/dsh-client-remotes/client": ["./packages/client/remotes/src/client/index.ts"], "@deepseek-ai/dsh-client-hmr": ["./packages/client/hmr/src"], "@deepseek-ai/dsh-client-modules": ["./packages/client/modules/src"], "@deepseek-ai/dsh-client-runtime": ["./packages/client/runtime/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 03a2b8bb59..b0567f762e 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -51,6 +51,8 @@ { "path": "./packages/client/modules" }, { "path": "./packages/client/hmr" }, { "path": "./packages/client/connection" }, + { "path": "./packages/typert/registry" }, + { "path": "./packages/host/api-gateway" }, { "path": "./packages/client/runtime" }, { "path": "./packages/client/test-runtime" }, { "path": "./packages/client/ui-layout" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 79276dfe7e..37c20c0d5c 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -100,7 +100,9 @@ { "path": "./packages/llm/token-meter" }, { "path": "./packages/core/session" }, { "path": "./packages/core/scope" }, + { "path": "./packages/typert/type-meta" }, { "path": "./packages/typert/registry" }, + { "path": "./packages/host/api-gateway" }, { "path": "./packages/typert/loader" }, { "path": "./packages/session-persistence/session-persistence" }, { "path": "./packages/session-persistence/session-checkpoint-policy" }, diff --git a/tsdown.config.ts b/tsdown.config.ts index 41a490436f..0d503c62d3 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from 'tsdown' +import { typertPlugin } from './packages/typert/generator/lib/types/tsdown-plugin.js' /** * JS bundling for vendored Cordis and Harness TypeScript packages. @@ -27,4 +28,7 @@ export default defineConfig({ fixedExtension: false, dts: false, clean: false, + // The final pass sees both independent TypeScript faces. Workspace mode + // writes only packages that explicitly publish a Typert/Remote subpath. + plugins: [typertPlugin({ mode: 'workspace' })], }) diff --git a/tsdown.typert-host.config.ts b/tsdown.typert-host.config.ts new file mode 100644 index 0000000000..8c8ae11dd1 --- /dev/null +++ b/tsdown.typert-host.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'tsdown' +import { typertPlugin } from './packages/typert/generator/lib/types/tsdown-plugin.js' + +/** + * Host-only TypeRT contract prepass. The generator and its project references + * are compiled first; the plugin then analyzes Host source and emits local and + * Host-for-Client artifacts before either aggregate consumes Remote subpaths. + */ +export default defineConfig({ + workspace: ['packages/typert/generator'], + entry: ['lib/types/{index,invariant}.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + plugins: [typertPlugin({ mode: 'workspace', faces: ['host'] })], +}) diff --git a/vitest.config.ts b/vitest.config.ts index cd37feb300..4c4c668b94 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url' import tsconfigPaths from 'vite-tsconfig-paths' import { resolvePwshPath } from './packages/bash/pwsh-local/src/resolve.ts' import { defineConfig } from 'vitest/config' +import ts from 'typescript' import { vitestExecArgv } from './vitest.shared.ts' import { COVERAGE_EXEMPT_ENV, coverageExemptHeavySuites } from './scripts/coverage-exempt.ts' @@ -17,6 +18,29 @@ const uncoveredLocationsReporter = fileURLToPath(new URL('./scripts/coverage-unc // map applies to every test file. paths must win over package exports so built // lib/ never loads a second module-singleton copy. const pathsPlugin = (): ReturnType => tsconfigPaths({ projects: ['./tsconfig.base.json'] }) +const decoratorSyntax = /^\s*@[A-Za-z_$][\w$]*/m + +const standardDecoratorPlugin = () => ({ + name: 'dsh-standard-decorators', + enforce: 'pre' as const, + transform(code: string, id: string) { + const file = id.split('?', 1)[0]! + if (!/\.[cm]?tsx?$/.test(file) || !decoratorSyntax.test(code)) return + const result = ts.transpileModule(code, { + fileName: file, + compilerOptions: { + target: ts.ScriptTarget.ES2024, + module: ts.ModuleKind.ESNext, + jsx: file.endsWith('x') ? ts.JsxEmit.ReactJSX : undefined, + sourceMap: true, + }, + }) + return { + code: result.outputText.replace(/\n?\/\/# sourceMappingURL=.*$/u, '\n'), + map: result.sourceMapText, + } + }, +}) const windowsUnsupportedPackages = process.platform === 'win32' ? [ @@ -88,7 +112,7 @@ const processBoundTests = [ ] export default defineConfig({ - plugins: [pathsPlugin()], + plugins: [pathsPlugin(), standardDecoratorPlugin()], test: { setupFiles: ['./scripts/test-invariants.ts'], // .tsx: client component specs (jsdom via per-file @vitest-environment pragma). @@ -99,7 +123,7 @@ export default defineConfig({ // always fork. projects: [ { - plugins: [pathsPlugin()], + plugins: [pathsPlugin(), standardDecoratorPlugin()], test: { name: 'thread-safe', execArgv: vitestExecArgv, @@ -119,7 +143,7 @@ export default defineConfig({ }, }, { - plugins: [pathsPlugin()], + plugins: [pathsPlugin(), standardDecoratorPlugin()], test: { name: 'process-bound', execArgv: vitestExecArgv, From 9a0a9350c44bf20e57c37daace7fb6746e5d9d00 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 5 Aug 2026 23:18:35 +0800 Subject: [PATCH 059/104] fix(typert): satisfy workspace static gates --- THIRD_PARTY_NOTICES.md | 1 + docs/cordis-catalog/services.md | 2 +- knip.json | 3 +- packages/client/connection/src/client/rpc.ts | 1 - packages/client/connection/src/http-bridge.ts | 6 +- packages/client/connection/src/rpc-host.ts | 26 +- .../connection/tests/client-apply.spec.ts | 43 +++ .../connection/tests/http-bridge.spec.ts | 2 +- .../client/connection/tests/node-half.spec.ts | 68 +++- packages/host/api-gateway/package.json | 4 +- packages/host/api-gateway/src/client/index.ts | 9 +- packages/host/api-gateway/src/index.ts | 10 +- .../host/api-gateway/tests/client.spec.ts | 138 ++++++++ .../host/api-gateway/tests/gateway.spec.ts | 294 ++++++++++++++++++ packages/typert/generator/src/emitter.ts | 27 +- .../typert/generator/src/tsdown-plugin.ts | 35 ++- .../generator/tests/tsdown-plugin.spec.ts | 7 + packages/typert/registry/src/service.ts | 7 + packages/typert/registry/tests/typert.spec.ts | 149 +++++++++ packages/typert/type-meta/package.json | 4 +- .../typert/type-meta/tests/type-meta.spec.ts | 74 +++++ pnpm-lock.yaml | 3 + python/sdk-runtime/package.json | 1 + scripts/check-workspace-constraints.ts | 34 +- scripts/dev-web.spec.ts | 8 +- scripts/dev-web.ts | 23 +- scripts/publication-payload.spec.ts | 31 +- scripts/publication-payload.ts | 36 ++- scripts/publish-npm-baseline.ts | 14 +- 29 files changed, 986 insertions(+), 74 deletions(-) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index f70245d49d..e53dd292e4 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -39,6 +39,7 @@ External packages that a workspace package resolves at runtime. `scripts/install | [`@clack/prompts`](https://github.com/bombshell-dev/clack) | MIT | | [`@earendil-works/pi-ai`](https://github.com/earendil-works/pi) | MIT | | [`@joplin/turndown-plugin-gfm`](https://github.com/laurent22/joplin-turndown-plugin-gfm) | MIT | +| [`@jridgewell/gen-mapping`](https://github.com/jridgewell/sourcemaps) | MIT | | [`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/typescript-sdk) | MIT | | [`@opentelemetry/api`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | | [`@opentelemetry/api-logs`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 0a9af0bae5..41059aebf4 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2585,7 +2585,7 @@ listPackages(filter: TypertPackageFilter = {}): TypertPackageRecord[] toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema.BaseSchema ``` -Source: [`packages/typert/registry/src/service.ts:319`](../../packages/typert/registry/src/service.ts) +Source: [`packages/typert/registry/src/service.ts:324`](../../packages/typert/registry/src/service.ts) ## `ctx.typertGateway` — `TypertGatewayService` diff --git a/knip.json b/knip.json index 7a3922c8fb..32c9e20dbf 100644 --- a/knip.json +++ b/knip.json @@ -200,7 +200,8 @@ "packages/typert/generator": { "entry": [ "tests/**/*.spec.ts", - "tests/fixtures/type-model/**/*.ts" + "tests/fixtures/type-model/**/*.ts", + "tests/fixtures/remote-model/**/*.ts" ], "project": [ "src/**/*.ts", diff --git a/packages/client/connection/src/client/rpc.ts b/packages/client/connection/src/client/rpc.ts index 36e16426b2..0c12149d7b 100644 --- a/packages/client/connection/src/client/rpc.ts +++ b/packages/client/connection/src/client/rpc.ts @@ -67,7 +67,6 @@ function resolveBase(): string { function assertTarget(channel: string, endpoint: string): void { const segments = endpoint.split('/') if (!CHANNEL_PATTERN.test(channel) - || segments.length === 0 || segments.some(segment => segment === '' || segment === '.' || segment === '..' || !ENDPOINT_SEGMENT_PATTERN.test(segment))) { throw new Error(`connection: invalid RPC target ${JSON.stringify(`${channel}/${endpoint}`)}`) diff --git a/packages/client/connection/src/http-bridge.ts b/packages/client/connection/src/http-bridge.ts index 319d3e0b0b..88d577bef8 100644 --- a/packages/client/connection/src/http-bridge.ts +++ b/packages/client/connection/src/http-bridge.ts @@ -5,6 +5,10 @@ import type { IncomingMessage, ServerResponse } from 'node:http' +interface FetchHandler { + fetch(request: Request): Promise +} + /** * Bridge one node:http request to the fetch-shaped handler (client close * aborts; SSE bodies stream out chunk by chunk). @@ -12,7 +16,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http' * @param res - node:http response the bridge writes and owns to completion. * @param apiHandler - fetch-shaped API carrier the request is dispatched to. */ -export async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: { fetch: typeof fetch }): Promise { +export async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: FetchHandler): Promise { const abort = new AbortController() // Client-disconnect detection MUST hang off the response, not the request: // since Node 16, IncomingMessage 'close' fires as soon as the request body is diff --git a/packages/client/connection/src/rpc-host.ts b/packages/client/connection/src/rpc-host.ts index be9eedca8f..a6fbdb0264 100644 --- a/packages/client/connection/src/rpc-host.ts +++ b/packages/client/connection/src/rpc-host.ts @@ -7,6 +7,7 @@ import { RpcId, type ClientRequest, type RpcError, + type RpcErrorDetailsMap, type RpcId as RpcIdType, type ServerResponse as RpcServerResponse, } from '@deepseek-ai/dsh-host-apiproxy/api' @@ -73,10 +74,9 @@ export class HostConnectionService extends Service implements HostConnectionHand function rpcFetchHandler( channel: string, handler: ConnectionRpcHandler, -): { fetch: typeof fetch } { +): { fetch(request: Request): Promise } { return { - async fetch(input: RequestInfo | URL, init?: RequestInit): Promise { - const request = input instanceof Request ? input : new Request(input, init) + async fetch(request: Request): Promise { const endpoint = endpointFromPath(channel, new URL(request.url).pathname) if (request.method !== 'POST' || endpoint === undefined) { return new Response('not found', { status: 404 }) @@ -96,13 +96,7 @@ function rpcFetchHandler( const envelope = clientRequestSchema.safeParse(body) if (!envelope.success) { - 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 }, - }) + return invalidEnvelopeResponse(body, envelope.error.issues) } const message: ClientRequest = envelope.data if (message.method !== endpoint) { @@ -123,11 +117,21 @@ function rpcFetchHandler( } } +function invalidEnvelopeResponse(body: unknown, issues: RpcErrorDetailsMap['bad-request']['issues']): Response { + 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 }, + }) +} + function endpointFromPath(channel: string, pathname: string): string | undefined { if (!pathname.startsWith(`${channel}/`)) return undefined const endpoint = pathname.slice(channel.length + 1) const segments = endpoint.split('/') - if (segments.length === 0 || segments.some(segment => + if (segments.some(segment => segment === '' || segment === '.' || segment === '..' || !ENDPOINT_SEGMENT_PATTERN.test(segment))) { return undefined } diff --git a/packages/client/connection/tests/client-apply.spec.ts b/packages/client/connection/tests/client-apply.spec.ts index d93844a2b8..3ce8b89ecb 100644 --- a/packages/client/connection/tests/client-apply.spec.ts +++ b/packages/client/connection/tests/client-apply.spec.ts @@ -235,6 +235,49 @@ describe('connection client apply', () => { }) }) + it('validates generic RPC transport failures, correlation, and targets', async () => { + ;(globalThis as Win).location = { + hostname: 'harness.example', search: '', origin: 'https://harness.example', + } + const handle = await mount() + const original = globalThis.fetch + const abort = new AbortController() + globalThis.fetch = vi.fn().mockResolvedValue(new Response('unavailable', { status: 503 })) + try { + await expect(handle.rpc.call('/api2', 'goals/create', {}, abort.signal)) + .rejects.toThrow('HTTP 503') + expect(globalThis.fetch).toHaveBeenCalledWith( + new URL('https://harness.example/api2/goals/create'), + expect.objectContaining({ signal: abort.signal }), + ) + + ;(globalThis as Win).location = { hostname: 'localhost', search: '', origin: 'null' } + globalThis.fetch = vi.fn().mockResolvedValue(Response.json({ + type: 'server-response', + rpcId: 'different-rpc', + result: { ok: true, value: null }, + })) + await expect(handle.rpc.call('/api2', 'goals/create', {})).rejects.toThrow('rpcId mismatch') + const fetch = vi.mocked(globalThis.fetch) + expect(fetch.mock.calls[0]?.[0]).toEqual(new URL('http://dsh.internal/api2/goals/create')) + expect(fetch.mock.calls[0]?.[1]).not.toHaveProperty('signal') + } finally { + globalThis.fetch = original + } + + for (const [channel, endpoint] of [ + ['api2', 'goals/create'], + ['/api2/path', 'goals/create'], + ['/api2', ''], + ['/api2', '.'], + ['/api2', '..'], + ['/api2', 'goals//create'], + ['/api2', 'goals/create?unsafe'], + ] as const) { + await expect(handle.rpc.call(channel, endpoint, {})).rejects.toThrow('invalid RPC target') + } + }) + it('keeps generic Remote calls unavailable in the client-only fixture', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } const handle = await mount() diff --git a/packages/client/connection/tests/http-bridge.spec.ts b/packages/client/connection/tests/http-bridge.spec.ts index 4607f32bae..b06834e523 100644 --- a/packages/client/connection/tests/http-bridge.spec.ts +++ b/packages/client/connection/tests/http-bridge.spec.ts @@ -28,7 +28,7 @@ describe('HTTP bridge abort', () => { let carrierSignal: AbortSignal | undefined const pending = bridge(request, response, { fetch: async (input) => { - const fetchRequest = input as Request + const fetchRequest = input carrierSignal = fetchRequest.signal resolveStarted() if (!fetchRequest.signal.aborted) { diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index af85d4e510..1c42a9dc88 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -47,6 +47,13 @@ function fakePost(headers: Record, url: string, body: unknown): return request } +/** Raw POST for malformed-body and media-type boundary cases. */ +function fakeRawPost(headers: Record, url: string, body: string): IncomingMessage { + const request = Readable.from([Buffer.from(body)]) as unknown as IncomingMessage + Object.assign(request, { url, method: 'POST', headers }) + return request +} + /** Response recorder compatible with both the fence's short-circuit and the bridge. */ function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown } } { const state: { status?: number; body?: unknown } = {} @@ -239,7 +246,10 @@ describe('connection node half', () => { const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.example'] }) await fiber.await() const connection = ctx.get('connection') as HostConnectionHandle - const remove = connection.rpc.handle('/api2', async () => ({ ok: true, value: null }), { + const remove = connection.rpc.handle('/api2', async (endpoint) => { + if (endpoint === 'fail') throw new Error('handler broke') + return { ok: true, value: null } + }, { authority: 'trusted-host', }) const route = routes[0]! @@ -248,14 +258,64 @@ describe('connection node half', () => { await route.handler(fakePost({ host: 'other.example' }, '/api2/goals/create', {}), denied.response) expect(denied.state).toMatchObject({ status: 403, body: 'forbidden' }) - const badEnvelope = fakeResponse() + const methodMismatch = fakeResponse() await route.handler(fakePost({ host: 'harness.example' }, '/api2/goals/create', { type: 'client-request', rpcId: 'rpc-bad', method: 'other', payload: {}, - }), badEnvelope.response) - expect(JSON.parse(String(badEnvelope.state.body))).toMatchObject({ + }), methodMismatch.response) + expect(JSON.parse(String(methodMismatch.state.body))).toMatchObject({ rpcId: 'rpc-bad', result: { ok: false, error: { code: 'bad-request' } }, }) + + for (const [request, status] of [ + [fakeRequest({ host: 'harness.example' }, '/api2/goals/create'), 404], + [fakePost({ host: 'harness.example' }, '/outside/goals/create', {}), 404], + [fakePost({ host: 'harness.example' }, '/api2/goals//create', {}), 404], + [fakeRawPost({ host: 'harness.example' }, '/api2/goals/create', '{}'), 415], + [fakeRawPost({ host: 'harness.example', 'content-type': 'text/plain' }, '/api2/goals/create', '{}'), 415], + [fakeRawPost({ host: 'harness.example', 'content-type': 'application/json; charset=utf-8' }, '/api2/goals/create', '{'), 400], + ] as const) { + const response = fakeResponse() + await route.handler(request, response.response) + expect(response.state.status).toBe(status) + } + + for (const [body, rpcId] of [ + [{ rpcId: 'retained-id' }, 'retained-id'], + [{ rpcId: 42 }, 'invalid-request'], + [null, 'invalid-request'], + ] as const) { + const response = fakeResponse() + await route.handler(fakePost({ host: 'harness.example' }, '/api2/goals/create', body), response.response) + expect(JSON.parse(String(response.state.body))).toMatchObject({ + rpcId, + result: { ok: false, error: { code: 'bad-request' } }, + }) + } + + const failed = fakeResponse() + await route.handler(fakePost({ host: 'harness.example' }, '/api2/fail', { + type: 'client-request', rpcId: 'rpc-fail', method: 'fail', payload: {}, + }), failed.response) + expect(failed.state).toMatchObject({ status: 500, body: 'handler failure: Error: handler broke' }) + + expect(() => connection.rpc.handle('/api', async () => ({ ok: true, value: null }), { + authority: 'loopback', + })).toThrow('invalid or reserved RPC channel') + expect(() => connection.rpc.handle('api3', async () => ({ ok: true, value: null }), { + authority: 'loopback', + })).toThrow('invalid or reserved RPC channel') + + const removeLoopback = connection.rpc.handle('/loopback', async () => ({ ok: true, value: null }), { + authority: 'loopback', + }) + const loopbackRoute = routes.find(candidate => candidate.path === '/loopback')! + const publicResponse = fakeResponse() + await loopbackRoute.handler(fakePost({ host: 'harness.example' }, '/loopback/read', { + type: 'client-request', rpcId: 'rpc-public', method: 'read', payload: {}, + }), publicResponse.response) + expect(publicResponse.state.status).toBe(403) + await removeLoopback() await remove() await fiber.dispose() }) diff --git a/packages/host/api-gateway/package.json b/packages/host/api-gateway/package.json index 3f3c905f1d..794ae323aa 100644 --- a/packages/host/api-gateway/package.json +++ b/packages/host/api-gateway/package.json @@ -43,9 +43,7 @@ "lib/invariant.js", "lib/client.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/host/api-gateway/src/client/index.ts b/packages/host/api-gateway/src/client/index.ts index 57116db2cf..fe8fd9f1b3 100644 --- a/packages/host/api-gateway/src/client/index.ts +++ b/packages/host/api-gateway/src/client/index.ts @@ -90,7 +90,8 @@ class ClientApiService extends Service implements ClientApi { } }, `api-gateway.client.mount(${JSON.stringify(contribution.package)})`) } catch (error) { - disposeRemote().catch(() => {}) + /* v8 ignore next -- rollback disposal only rejects if Cordis teardown itself fails while handling the installation error. */ + Promise.resolve(disposeRemote()).catch(() => {}) throw error } return async () => { @@ -148,6 +149,7 @@ class ClientApiService extends Service implements ClientApi { const projection = scopedProjection(descriptor) if (projection !== undefined) installed.push(this.installScoped(descriptor, projection, token)) return () => { + /* v8 ignore next -- Cordis effect disposers are idempotent and invoke this cleanup at most once. */ if (!token.active) return token.active = false for (const dispose of installed.reverse()) dispose() @@ -173,6 +175,7 @@ class ClientApiService extends Service implements ClientApi { value: (...args: unknown[]) => this.invoke(descriptor, undefined, token, this.ownerCtx, args), }) return () => { + /* v8 ignore next -- duplicate live methods are rejected before installation, so no newer token can replace this one. */ if (namespace.tokens.get(descriptor.method) !== token) return Reflect.deleteProperty(namespace.value, descriptor.method) namespace.tokens.delete(descriptor.method) @@ -203,6 +206,7 @@ class ClientApiService extends Service implements ClientApi { namespace.tokens.set(descriptor.method, token) namespace.service.install(descriptor, projection, token) return () => { + /* v8 ignore next -- duplicate live methods are rejected before installation, so no newer token can replace this one. */ if (namespace.tokens.get(descriptor.method) !== token) return namespace.service.remove(descriptor.method) namespace.tokens.delete(descriptor.method) @@ -289,9 +293,6 @@ class ScopedRemoteNamespace extends Service { }, }) this.methods.add(method) - if (this.methods.size === 1 && this.ownerCtx.get(this.name, false) === undefined) { - this.ownerCtx.set(this.name, this) - } } remove(method: string): void { diff --git a/packages/host/api-gateway/src/index.ts b/packages/host/api-gateway/src/index.ts index ccb76e2d48..c83772261a 100644 --- a/packages/host/api-gateway/src/index.ts +++ b/packages/host/api-gateway/src/index.ts @@ -156,11 +156,10 @@ export class TypertGatewayService extends Service implements TypertGateway { private async invokeRpc(endpoint: string, payload: unknown): Promise { try { const segments = endpoint.split('/') - const namespace = segments[0] - const method = segments[1] - if (segments.length !== 2 || namespace === undefined || namespace === '' || method === undefined || method === '') { + if (segments.length !== 2 || segments[0] === '' || segments[1] === '') { throw new Error(`invalid Remote endpoint ${JSON.stringify(endpoint)}`) } + const [namespace, method] = segments as [string, string] if (!isObject(payload) || !isPlainObject(payload) || Reflect.ownKeys(payload).length !== 1 @@ -358,6 +357,7 @@ export class TypertGatewayService extends Service implements TypertGateway { const value = decode(parameter.codec, args[parameter.wire], 'input-invalid', endpoint, parameter.wire) if (parameter.source === 'json') return value const key = parameter.lookup + /* v8 ignore next -- registry validation rejects strict descriptors without a key, and SRC derivation always supplies one. */ if (key === undefined) { throw new TypertGatewayError( 'lookup-unavailable', @@ -492,11 +492,11 @@ function methodParameterNames(service: object, method: string, endpoint: string) const source = Function.prototype.toString.call(implementation) const open = source.indexOf('(') const close = source.indexOf(')', open + 1) + /* v8 ignore next -- standard public class-method syntax always contains a parenthesized parameter list. */ if (open < 0 || close < 0) return invalidSignature(endpoint, method) const body = source.slice(open + 1, close).trim() if (body.length === 0) return [] const parts = body.split(',').map(part => part.trim()) - if (parts.at(-1) === '') parts.pop() const names = new Set() for (const part of parts) { if (!/^[$A-Z_a-z][$\w]*$/u.test(part) || names.has(part)) return invalidSignature(endpoint, method) @@ -579,8 +579,8 @@ function assertJsonValue(value: unknown, ancestors: Set): void { if (!isPlainObject(value)) throw new TypeError('non-plain object is not JSON-safe') if (Object.getOwnPropertySymbols(value).length > 0) throw new TypeError('symbol property is not JSON-safe') for (const key of Reflect.ownKeys(value)) { - if (typeof key !== 'string') throw new TypeError('symbol property is not JSON-safe') const descriptor = Object.getOwnPropertyDescriptor(value, key) + /* v8 ignore next -- ownKeys() just returned this key; only a hostile same-process Proxy can delete it between operations. */ if (descriptor === undefined || !descriptor.enumerable || !('value' in descriptor)) { throw new TypeError('non-data property is not JSON-safe') } diff --git a/packages/host/api-gateway/tests/client.spec.ts b/packages/host/api-gateway/tests/client.spec.ts index be0b12ed51..8c0753f3f9 100644 --- a/packages/host/api-gateway/tests/client.spec.ts +++ b/packages/host/api-gateway/tests/client.spec.ts @@ -203,6 +203,144 @@ describe('Client TypeRT API', () => { expect(ctx.typert.remotes.list()).toEqual([]) }) + it('rejects duplicate, live, scoped-service, and Context namespace collisions', async () => { + const ctx = await bench(vi.fn()) + const direct = directDescriptor() + const context = contextDescriptor() + + expect(() => ctx.api.mount({ + package: '@fixture/direct-duplicates', + descriptors: [direct, { ...direct, id: '@fixture/goals#goals/create-again' }], + })).toThrow('repeats direct method') + expect(() => ctx.api.mount({ + package: '@fixture/scoped-duplicates', + descriptors: [context, { ...context, id: '@fixture/goals#goals/rename-again' }], + })).toThrow('repeats scoped method') + + const disposeDirect = ctx.api.mount({ package: '@fixture/direct-live', descriptors: [direct] }) + expect(() => ctx.api.mount({ + package: '@fixture/direct-conflict', descriptors: [{ ...direct, id: '@fixture/other#goals/create' }], + })).toThrow('direct method goals/create is already mounted') + await disposeDirect() + + const disposeScoped = ctx.api.mount({ package: '@fixture/scoped-live', descriptors: [context] }) + expect(() => ctx.api.mount({ + package: '@fixture/scoped-conflict', descriptors: [{ ...context, id: '@fixture/other#goals/rename' }], + })).toThrow('scoped method goals/rename is already mounted') + expect(() => ctx.api.mount({ + package: '@fixture/service-method-conflict', + descriptors: [{ ...context, id: '@fixture/goals#goals/remove', method: 'remove' }], + })).toThrow('conflicts with its namespace service') + await disposeScoped() + + expect(() => ctx.api.mount({ + package: '@fixture/context-property-conflict', + descriptors: [{ ...context, namespace: 'typert' }], + })).toThrow('conflicts with an existing Context property') + + const disposeMultipleScoped = ctx.api.mount({ + package: '@fixture/multiple-scoped', + descriptors: [directDescriptor(), contextDescriptor()], + }) + await disposeMultipleScoped() + }) + + it('rejects weak parameter and Context codecs plus malformed scope projections', async () => { + const ctx = await bench(vi.fn()) + const direct = directDescriptor() + const context = contextDescriptor() + expect(() => ctx.api.mount({ + package: '@fixture/weak-parameter', + descriptors: [{ + ...direct, + parameters: direct.parameters.map((parameter, index) => index === 0 + ? { ...parameter, codec: { mode: 'src-json' } } + : parameter), + }], + })).toThrow('has no strict codec') + expect(() => ctx.api.mount({ + package: '@fixture/weak-context', + descriptors: [{ + ...context, + invocation: { ...context.invocation, codec: { mode: 'src-json' } }, + } as InvocationDescriptor], + })).toThrow('has no strict codec') + expect(() => ctx.api.mount({ + package: '@fixture/malformed-scope', + descriptors: [{ ...direct, scope: { context: 'fixture', wire: 'missingId' } }], + })).toThrow('scope must select its only lookup parameter') + expect(() => ctx.api.mount({ + package: '@fixture/ambiguous-scope', + descriptors: [{ + ...direct, + parameters: [...direct.parameters, { + name: 'other', wire: 'otherId', source: 'lookup', lookup: 'fixture', + codec: { mode: 'strict', typeSymbol: '@fixture#AgentId', schema: idSchema }, + }], + }], + })).toThrow('scope must select its only lookup parameter') + }) + + it('validates invocation arity, required binders, live Connection, and mutable descriptor codecs', async () => { + const call = vi.fn() + .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } }) + const ctx = await bench(call) + const descriptor = directDescriptor() + const dispose = ctx.api.mount({ package: '@fixture/goals', descriptors: [descriptor] }) + const create = ctx.api.goals.create as unknown as (...args: unknown[]) => Promise + + await expect(create('agent-1')).rejects.toThrow('expected 2 argument(s), got 1') + await expect((ctx as FixtureContext).goals.create({ objective: 'ship' })) + .rejects.toThrow('no Client Context binder') + + ;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'src-json' + await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('has no strict codec') + ;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'strict' + + ctx.set('connection', undefined) + await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('no active Connection') + await dispose() + }) + + it('withdraws a pending invocation and preserves a direct namespace until its last method leaves', async () => { + let resolveCall!: (result: Awaited>) => void + const pending = new Promise>>((resolve) => { + resolveCall = resolve + }) + const call = vi.fn().mockReturnValue(pending) + const ctx = await bench(call) + const { scope: _scope, ...first } = directDescriptor() + const second: InvocationDescriptor = { + ...first, + id: '@fixture/goals#goals/archive', + method: 'archive', + } + const dispose = ctx.api.mount({ package: '@fixture/goals', descriptors: [first, second] }) + const invocation = ctx.api.goals.create('agent-1', { objective: 'ship' }) + await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) }) + await dispose() + resolveCall({ ok: true, value: { ref: 'goal-1' } }) + + await expect(invocation).rejects.toThrow('withdrawn during invocation') + expect((ctx.api as unknown as Record).goals).toBeUndefined() + }) + + it('rolls back Remote registration when concrete method installation fails', async () => { + const ctx = await bench(vi.fn()) + const defineProperty = Object.defineProperty + const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => { + if (key === 'goals') throw new Error('fixture installation failure') + return defineProperty(target, key, attributes) + }) + try { + expect(() => ctx.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })) + .toThrow('fixture installation failure') + await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) + } finally { + spy.mockRestore() + } + }) + it('throws RPC failures with the structured error as its cause', async () => { const rpcError = { code: 'internal' as const, message: 'host failed', details: {} } const ctx = await bench(vi.fn().mockResolvedValue({ ok: false, error: rpcError })) diff --git a/packages/host/api-gateway/tests/gateway.spec.ts b/packages/host/api-gateway/tests/gateway.spec.ts index 8f7c144f5e..0b550e126d 100644 --- a/packages/host/api-gateway/tests/gateway.spec.ts +++ b/packages/host/api-gateway/tests/gateway.spec.ts @@ -229,6 +229,96 @@ class WrongBindingService extends Service { } } +class ExportedMethodService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'exportedMethod', { namespace: 'exported' }) + + constructor(ctx: Context) { + super(ctx, 'exportedMethod') + } + + @Remote('execute') + run(value: string): string { + return value + } +} + +class EmptyMethodService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'emptyMethod', { namespace: 'empty' }) + + constructor(ctx: Context) { + super(ctx, 'emptyMethod') + } + + @Remote + ping(): string { + return 'pong' + } +} + +class CollidingWireService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'collidingWire', { namespace: 'colliding-wire' }) + + constructor(ctx: Context) { + super(ctx, 'collidingWire') + } + + @Remote + run(agent: FixtureAgent, agentId: string): string { + return `${agent.id}:${agentId}` + } +} + +class ContextWireService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'contextWire', { namespace: 'context-wire' }) + + constructor(ctx: Context) { + super(ctx, 'contextWire') + } + + @RemoteContext('gatewayFixture') + run(agentId: string): string { + return agentId + } +} + +class NoBindingService extends Service { + constructor(ctx: Context) { + super(ctx, 'noBinding') + } + + run(value: string): string { + return value + } +} + +class MissingMethodService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'missingMethod', { namespace: 'missing-method' }) + + constructor(ctx: Context) { + super(ctx, 'missingMethod') + } + + @Remote + run(value: string): string { + return value + } +} + +class InheritedMethodBase extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'inheritedMethod', { namespace: 'inherited' }) + + constructor(ctx: Context) { + super(ctx, 'inheritedMethod') + } + + @Remote + run(value: string): string { + return value + } +} + +class InheritedMethodService extends InheritedMethodBase {} + describe('TypertGatewayService', () => { it('invokes a strict direct method with schema decoding and a live lookup', async () => { const { ctx, service } = await setup() @@ -284,6 +374,53 @@ describe('TypertGatewayService', () => { })).resolves.toEqual({ title: 'land', scope: 'agent-src' }) }) + it('derives exported, empty, inherited, and distinct-namespace SRC methods', async () => { + const ctx = await setupGateway() + await ctx.plugin(ExportedMethodService) + await ctx.plugin(EmptyMethodService) + await ctx.plugin(InheritedMethodService) + + await expect(ctx.typertGateway.invoke({ + namespace: 'exported', method: 'execute', args: { value: 'ship' }, + })).resolves.toBe('ship') + await expect(ctx.typertGateway.invoke({ + namespace: 'empty', method: 'ping', args: {}, + })).resolves.toBe('pong') + await expect(ctx.typertGateway.invoke({ + namespace: 'inherited', method: 'run', args: { value: 'land' }, + })).resolves.toBe('land') + await expectCode(ctx.typertGateway.invoke({ + namespace: 'other', method: 'absent', args: {}, + }), 'invocation-unavailable') + }) + + it('rejects SRC wire collisions and unavailable Context providers', async () => { + const colliding = await setupGateway() + await colliding.plugin(CollidingWireService) + registerAgentLookup(colliding, { id: 'agent-1' }) + await expectCode(colliding.typertGateway.invoke({ + namespace: 'colliding-wire', + method: 'run', + args: { agentId: 'agent-1' }, + }), 'signature-invalid') + + const missing = await setup() + await expectCode(missing.ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'rename', + args: { agentId: 'agent-1', request: { title: 'land' } }, + }), 'context-unavailable') + + const contextCollision = await setupGateway() + await contextCollision.plugin(ContextWireService) + contextCollision.typert.contexts.registerHost('gatewayFixture', contextProvider(contextCollision.extend())) + await expectCode(contextCollision.typertGateway.invoke({ + namespace: 'context-wire', + method: 'run', + args: { agentId: 'agent-1' }, + }), 'signature-invalid') + }) + it('re-reads Service and providers on every strict invocation', async () => { const { ctx, serviceFiber } = await setup() const agent = { id: 'agent-1' } @@ -331,6 +468,58 @@ describe('TypertGatewayService', () => { expect(error.cause).toEqual(new Error('provider failed')) }) + it('reports Context provider metadata mismatch and unresolved identities', async () => { + const { ctx } = await setup() + registerStrict(ctx, [renameDescriptor()]) + const scoped = ctx.extend() + const mismatch = ctx.typert.contexts.registerHost('gatewayFixture', { + ...contextProvider(scoped), + wire: 'differentAgentId', + }) + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'rename', + args: { agentId: 'agent-1', request: { title: 'land' } }, + }), 'provider-mismatch') + await mismatch() + + ctx.typert.contexts.registerHost('gatewayFixture', { + ...contextProvider(scoped), + resolve: () => undefined, + }) + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'rename', + args: { agentId: 'agent-1', request: { title: 'land' } }, + }), 'context-not-found') + }) + + it('contains lookup provider failures and missing identities', async () => { + const { ctx } = await setup() + registerStrict(ctx, [createDescriptor()]) + const throwing = ctx.typert.lookups.register('gatewayFixture', { + ...agentLookup({ id: 'agent-1' }), + resolve: () => { throw new Error('lookup failed') }, + }) + const failure = await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: { agentId: 'agent-1', request: { title: 'ship' } }, + }), 'lookup-failed') + expect(failure.cause).toEqual(new Error('lookup failed')) + await throwing() + + ctx.typert.lookups.register('gatewayFixture', { + ...agentLookup({ id: 'agent-1' }), + resolve: () => undefined, + }) + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: { agentId: 'agent-1', request: { title: 'ship' } }, + }), 'lookup-not-found') + }) + it('never downgrades an observed strict endpoint after definition disposal', async () => { const { ctx } = await setup() const dispose = registerStrict(ctx, [passthroughDescriptor()]) @@ -434,6 +623,11 @@ describe('TypertGatewayService', () => { method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' }, optional: true }, }), 'arguments-invalid') + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: [] as unknown as Record, + }), 'arguments-invalid') expect(service.calls).toEqual([]) }) @@ -492,6 +686,31 @@ describe('TypertGatewayService', () => { }), 'result-invalid') }) + it('accepts dense JSON and rejects decorated arrays and object properties', async () => { + const { ctx } = await setup() + await expect(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'passthrough', + args: { value: [1, { nested: true }] }, + })).resolves.toEqual([1, { nested: true }]) + + const sparseWithExtra = Array(1) as unknown[] & { extra?: boolean } + sparseWithExtra.extra = true + const symbolArray = [1] + Object.defineProperty(symbolArray, Symbol('extra'), { value: true }) + const symbolObject = { value: true } + Object.defineProperty(symbolObject, Symbol('extra'), { value: true }) + const hidden = {} + Object.defineProperty(hidden, 'value', { value: true, enumerable: false }) + const accessor = {} + Object.defineProperty(accessor, 'value', { get: () => true, enumerable: true }) + for (const value of [sparseWithExtra, symbolArray, symbolObject, hidden, accessor]) { + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', method: 'passthrough', args: { value }, + }), 'input-invalid') + } + }) + it('validates strict provider identity against generated wire metadata', async () => { const { ctx } = await setup() ctx.typert.lookups.register('gatewayFixture', { @@ -525,6 +744,61 @@ describe('TypertGatewayService', () => { }), 'method-unavailable') }) + it('requires a visible binding and supports explicitly provided plain Services', async () => { + const ctx = await setupGateway() + await ctx.plugin(NoBindingService) + registerStrict(ctx, [{ + ...passthroughDescriptor(), + id: '@fixture/gateway#no-binding/run', + service: 'noBinding', + namespace: 'no-binding', + method: 'run', + }]) + await expectCode(ctx.typertGateway.invoke({ + namespace: 'no-binding', method: 'run', args: { value: 'ship' }, + }), 'binding-invalid') + + const plain: { + typertGateway?: ReturnType + run(value: string): string + } = { run: value => value } + plain.typertGateway = bindTypeRTGateway(plain, 'plainRemote', { namespace: 'plain' }) + ctx.provide('plainRemote', plain) + ctx.typert.register({ + package: '@fixture/plain', + face: 'host', + schemas: [], + model: emptyModel, + invocations: [{ + ...passthroughDescriptor(), + id: '@fixture/plain#plain/run', + service: 'plainRemote', + namespace: 'plain', + method: 'run', + }], + }) + await expect(ctx.typertGateway.invoke({ + namespace: 'plain', method: 'run', args: { value: 'land' }, + })).resolves.toBe('land') + }) + + it('reports a SRC marker whose prototype implementation disappeared', async () => { + const ctx = await setupGateway() + await ctx.plugin(MissingMethodService) + const descriptor = Object.getOwnPropertyDescriptor(MissingMethodService.prototype, 'run')! + Object.defineProperty(MissingMethodService.prototype, 'run', { + configurable: true, + value: 42, + }) + try { + await expectCode(ctx.typertGateway.invoke({ + namespace: 'missing-method', method: 'run', args: { value: 'ship' }, + }), 'method-unavailable') + } finally { + Object.defineProperty(MissingMethodService.prototype, 'run', descriptor) + } + }) + it('preserves business exception identity after invocation begins', async () => { const { ctx, service } = await setup() const failure = new Error('business identity') @@ -575,6 +849,26 @@ describe('TypertGatewayService', () => { if (invalid.ok) throw new Error('invalid Remote payload unexpectedly succeeded') expect(invalid.error.message).toMatch(/exactly one plain-object args field/) + for (const endpoint of ['goals', '/create', 'goals/', 'goals/create/extra']) { + const result = await handler(endpoint, { args: {} }, signal) + expect(result).toMatchObject({ ok: false, error: { code: 'internal' } }) + if (result.ok) throw new Error('invalid Remote endpoint unexpectedly succeeded') + expect(result.error.message).toContain('invalid Remote endpoint') + } + for (const payload of [null, [], { args: {}, extra: true }, { only: true }, { args: null }, { args: [] }]) { + const result = await handler('goals/create', payload, signal) + expect(result).toMatchObject({ ok: false, error: { code: 'internal' } }) + if (result.ok) throw new Error('invalid Remote payload unexpectedly succeeded') + expect(result.error.message).toContain('plain-object args field') + } + + const service = rawGoalService(ctx) + service.businessError = 'non-error failure' as unknown as Error + await expect(handler('goals/fail', { args: { request: null } }, signal)).resolves.toEqual({ + ok: false, + error: { code: 'internal', message: 'non-error failure', details: {} }, + }) + await gatewayFiber.dispose() expect(connection.handler).toBeUndefined() }) diff --git a/packages/typert/generator/src/emitter.ts b/packages/typert/generator/src/emitter.ts index 3e79780593..63b1ee7ace 100644 --- a/packages/typert/generator/src/emitter.ts +++ b/packages/typert/generator/src/emitter.ts @@ -399,21 +399,9 @@ export class FaceModelEmitter { scoped: boolean, ): void { const signature = this.remoteSignature(invocation, referenceNames, scoped) - const line = ` ${signature}` - lines.push(line) - const generatedLine = lines.length const keyLength = signature.indexOf(': (') if (keyLength < 0) throw new TypertEmitError(`Remote signature ${invocation.id} has no property delimiter`) - const source = remoteDeclarationSource(packageModel, invocation) - addMapping(sourceMap, { - generated: { line: generatedLine, column: 4 }, - source, - original: { line: invocation.location.line, column: invocation.location.column - 1 }, - name: invocation.method, - }) - addMapping(sourceMap, { - generated: { line: generatedLine, column: 4 + keyLength }, - }) + this.pushMappedRemoteSignature(lines, sourceMap, packageModel, invocation, signature, keyLength) } private pushRemoteNamespaceSignature( @@ -424,6 +412,17 @@ export class FaceModelEmitter { referenceNames: ReadonlyMap, ): void { const signature = `${invocation.method}: ${this.remoteFunctionType(invocation, referenceNames, false)}` + this.pushMappedRemoteSignature(lines, sourceMap, packageModel, invocation, signature, invocation.method.length) + } + + private pushMappedRemoteSignature( + lines: string[], + sourceMap: GenMapping, + packageModel: PackageModel, + invocation: InvocationModel, + signature: string, + keyLength: number, + ): void { lines.push(` ${signature}`) const generatedLine = lines.length const source = remoteDeclarationSource(packageModel, invocation) @@ -434,7 +433,7 @@ export class FaceModelEmitter { name: invocation.method, }) addMapping(sourceMap, { - generated: { line: generatedLine, column: 4 + invocation.method.length }, + generated: { line: generatedLine, column: 4 + keyLength }, }) } diff --git a/packages/typert/generator/src/tsdown-plugin.ts b/packages/typert/generator/src/tsdown-plugin.ts index a5c6ef93e2..10cba60974 100644 --- a/packages/typert/generator/src/tsdown-plugin.ts +++ b/packages/typert/generator/src/tsdown-plugin.ts @@ -1,23 +1,27 @@ /** - * Optional tsdown (rolldown) plugin face of the typert generator. When added - * to a workspace tsdown config, it runs after each opted-in package bundle is - * written and re-emits its model-driven face artifact at the package output - * root. Packages without a Typert or Remote export are skipped. + * Optional tsdown (rolldown) plugin face of the typert generator. It lowers + * standard decorators in TypeScript dependencies before bundling, then emits + * model-driven face artifacts at the package output root. Packages without a + * Typert or Remote export are skipped. * @module @deepseek-ai/dsh-typert-generator/tsdown */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' +import ts from 'typescript' import { WorkspaceTypertGenerator } from './workspace.ts' import type { WorkspaceEmitResult } from './workspace.ts' import type { TypertFace } from './model.ts' -/** The subset of the rolldown output-plugin contract this plugin uses (structural; avoids a rolldown type dependency). */ +/** The subset of the rolldown plugin contract used here (structural; avoids a rolldown type dependency). */ interface TypertPlugin { name: string + transform: (code: string, id: string) => { code: string; map: string | undefined } | undefined writeBundle: (options: { dir?: string }) => void } +const DECORATOR_SYNTAX = /^\s*@[A-Za-z_$][\w$]*/m + /** Generation scope selected by a tsdown build phase. */ export interface TypertPluginOptions { /** Package mode emits only the package being bundled; workspace mode emits every explicit contributor once. */ @@ -27,15 +31,32 @@ export interface TypertPluginOptions { } /** - * Create the typert generation plugin for the root tsdown config. + * Create the decorator-lowering and typert-generation plugin for the root tsdown config. * @param pluginOptions - package/workspace emission mode and independent program faces. - * @returns a rolldown-compatible plugin that emits local face and Host-for-Client Remote artifacts. + * @returns a rolldown-compatible plugin that lowers source decorators and emits local and Host-for-Client artifacts. */ export function typertPlugin(pluginOptions: TypertPluginOptions = {}): TypertPlugin { const artifactsByRoot = new Map() const emittedWorkspaces = new Set() return { name: 'dsh-typert-generator', + transform(code, id) { + const file = id.split('?', 1)[0] ?? id + if (!/\.[cm]?tsx?$/.test(file) || !DECORATOR_SYNTAX.test(code)) return + const result = ts.transpileModule(code, { + fileName: file, + compilerOptions: { + target: ts.ScriptTarget.ES2024, + module: ts.ModuleKind.ESNext, + ...(file.endsWith('x') ? { jsx: ts.JsxEmit.ReactJSX } : {}), + sourceMap: true, + }, + }) + return { + code: result.outputText.replace(/\n?\/\/# sourceMappingURL=.*$/u, '\n'), + map: result.sourceMapText, + } + }, writeBundle(bundleOptions) { // options.dir is the package's absolute outDir (/lib); its // nearest package.json owns the bundle even when a custom config writes diff --git a/packages/typert/generator/tests/tsdown-plugin.spec.ts b/packages/typert/generator/tests/tsdown-plugin.spec.ts index 655636aa79..106b8950ff 100644 --- a/packages/typert/generator/tests/tsdown-plugin.spec.ts +++ b/packages/typert/generator/tests/tsdown-plugin.spec.ts @@ -64,6 +64,13 @@ afterEach(() => { }) describe('typertPlugin', () => { + it('lowers standard decorators in TypeScript source dependencies', () => { + const plugin = typertPlugin() + expect(plugin.transform('export const value = 1\n', '/workspace/src/plain.ts')).toBeUndefined() + expect(plugin.transform('@sealed\nexport class Example {}\n', '/workspace/src/example.ts')?.code) + .not.toContain('@sealed') + }) + it('skips outputs that do not identify a Typert contributor', async () => { const plugin = typertPlugin() expect(plugin.name).toBe('dsh-typert-generator') diff --git a/packages/typert/registry/src/service.ts b/packages/typert/registry/src/service.ts index 16160a3860..4973732fad 100644 --- a/packages/typert/registry/src/service.ts +++ b/packages/typert/registry/src/service.ts @@ -149,8 +149,10 @@ class DescriptorStore { for (const descriptor of descriptors) { const endpoint = typertEndpoint(descriptor) const entry = this.entries.get(endpoint) + /* v8 ignore next -- duplicate registration is rejected, so no later owner can replace this entry before its effect disposes. */ if (entry?.owner !== owner) continue this.entries.delete(endpoint) + /* v8 ignore next -- ids and endpoints are committed and withdrawn together under the same unique owner. */ if (this.ids.get(descriptor.id) === entry) this.ids.delete(descriptor.id) removed.push(endpoint) } @@ -200,6 +202,7 @@ class RemoteStore { packages.set(contribution.package, owner) descriptors.commit(owner, contribution.descriptors) yield () => { + /* v8 ignore else -- duplicate package registration is rejected, so this effect remains the package's unique owner. */ if (packages.get(contribution.package) === owner) packages.delete(contribution.package) descriptors.withdraw(owner, contribution.descriptors) } @@ -244,6 +247,7 @@ class LookupStore { providers.set(key, entry) changes.emit({ kind: 'lookup', key }) yield () => { + /* v8 ignore next -- duplicate registration is rejected, so this effect remains the key's unique owner. */ if (providers.get(key) !== entry) return providers.delete(key) changes.emit({ kind: 'lookup', key }) @@ -303,6 +307,7 @@ class ContextStore { table.set(key, entry) changes.emit({ kind, key }) yield () => { + /* v8 ignore next -- duplicate registration is rejected, so this effect remains the key's unique owner. */ if (table.get(key) !== entry) return table.delete(key) changes.emit({ kind, key }) @@ -381,8 +386,10 @@ export class TypertRegistry extends Service implements TypeRTService { for (const record of schemaRecords) schemas.set(record.key, record) localStore.commit(owner, invocations) yield () => { + /* v8 ignore else -- duplicate package-face registration is rejected, so this effect remains its unique owner. */ if (packages.get(packageRecord.key) === packageRecord) packages.delete(packageRecord.key) for (const record of schemaRecords) { + /* v8 ignore else -- duplicate schema registration is rejected, so this contribution remains each record's unique owner. */ if (schemas.get(record.key) === record) schemas.delete(record.key) } localStore.withdraw(owner, invocations) diff --git a/packages/typert/registry/tests/typert.spec.ts b/packages/typert/registry/tests/typert.spec.ts index a98f99f912..95f8bc871f 100644 --- a/packages/typert/registry/tests/typert.spec.ts +++ b/packages/typert/registry/tests/typert.spec.ts @@ -13,6 +13,7 @@ import type { TypeRTLookup, TypeRTRemoteContribution, } from '@deepseek-ai/dsh-type-meta' +import { apply as applyClientRegistry, inject as clientRegistryInject } from '../src/client/index.ts' declare module '@deepseek-ai/dsh-type-meta' { interface TypeRTLookupMap { @@ -222,6 +223,29 @@ describe('TypertRegistry', () => { expect(changes).toEqual(['local:goals/create', 'local:goals/create']) }) + it('rejects duplicate invocation endpoints and ids atomically', async () => { + const ctx = await makeCtx() + const first = invocation() + ctx.typert.register({ ...toolsContribution(), invocations: [first] }) + + expect(() => ctx.typert.remotes.register({ + package: '@fixture/duplicate-endpoint', + descriptors: [invocation('@fixture/remote#first'), invocation('@fixture/remote#second')], + })).toThrow('endpoint "goals/create" is already registered') + expect(() => ctx.typert.remotes.register({ + package: '@fixture/duplicate-id', + descriptors: [ + invocation('@fixture/remote#same'), + { ...invocation('@fixture/remote#same'), method: 'rename' }, + ], + })).toThrow('invocation id "@fixture/remote#same" is already registered') + expect(() => ctx.typert.register({ + ...toolsContribution(), + package: '@fixture/existing-endpoint', + invocations: [{ ...first, id: '@fixture/local#other' }], + })).toThrow('endpoint "goals/create" is already registered') + }) + it('mounts Remote contributions in the calling fiber and withdraws them exactly', async () => { const ctx = await makeCtx() const descriptor = invocation() @@ -314,6 +338,131 @@ describe('TypertRegistry', () => { expect(ctx.typert.contexts.getClient('registryFixture')).toBeUndefined() }) + it('publishes provider changes, rejects duplicate providers, and disposes subscriptions', async () => { + const ctx = await makeCtx() + const changes: string[] = [] + const disposeLookupSubscription = ctx.typert.lookups.subscribe((change) => { + changes.push(`${change.kind}:${change.key}`) + }) + const disposeContextSubscription = ctx.typert.contexts.subscribe((change) => { + changes.push(`${change.kind}:${change.key}`) + }) + const lookup = { + parameter: 'agent', + wire: 'agentId', + hostTypeSymbol: '@fixture#Agent', + wireTypeSymbol: '@fixture#AgentId', + resolve: () => undefined, + } + const host = { + wire: 'agentId', + wireTypeSymbol: '@fixture#AgentId', + resolve: () => undefined, + } + const client = { identity: () => undefined } + const disposeLookup = ctx.typert.lookups.register('fixture', lookup) + const disposeHost = ctx.typert.contexts.registerHost('registryFixture', host) + const disposeClient = ctx.typert.contexts.registerClient('registryFixture', client) + + expect(() => ctx.typert.lookups.register('fixture', lookup)).toThrow('already registered') + expect(() => ctx.typert.contexts.registerHost('registryFixture', host)).toThrow('already registered') + expect(() => ctx.typert.contexts.registerClient('registryFixture', client)).toThrow('already registered') + await Promise.all([disposeLookup(), disposeHost(), disposeClient()]) + expect(changes).toEqual([ + 'lookup:fixture', + 'host-context:registryFixture', + 'client-context:registryFixture', + 'lookup:fixture', + 'host-context:registryFixture', + 'client-context:registryFixture', + ]) + + await Promise.all([disposeLookupSubscription(), disposeContextSubscription()]) + ctx.typert.lookups.register('fixture', lookup) + expect(changes).toHaveLength(6) + }) + + it('validates every invocation and provider boundary', async () => { + const ctx = await makeCtx() + const strict = { + mode: 'strict' as const, + typeSymbol: '@fixture#Value', + schema: z.string(), + } + const strictInvocation: InvocationDescriptor = { + ...invocation('@fixture/remote#strict'), + implementation: 'remoteExportCreate', + parameters: [{ name: 'request', wire: 'request', source: 'json', codec: strict }], + result: strict, + } + const dispose = ctx.typert.remotes.register({ package: '@fixture/strict', descriptors: [strictInvocation] }) + await dispose() + + const malformed: readonly [InvocationDescriptor, string][] = [ + [{ ...invocation(), id: '' }, 'invocation id'], + [{ ...invocation(), namespace: 'bad/name' }, 'namespace'], + [{ ...invocation(), implementation: 'bad/name' }, 'implementation method'], + [{ + ...invocation(), + parameters: [ + ...invocation().parameters, + { name: 'other', wire: 'request', source: 'json', codec: { mode: 'src-json' } }, + ], + }, 'repeats wire field'], + [{ + ...invocation(), + parameters: [{ name: 'agent', wire: 'agentId', source: 'lookup', codec: { mode: 'src-json' } }], + }, 'has no lookup key'], + [{ + ...invocation(), + parameters: [{ + name: 'request', wire: 'request', source: 'json', lookup: 'fixture', codec: { mode: 'src-json' }, + }], + }, 'JSON parameter'], + [{ + ...invocation(), + invocation: { + kind: 'context', context: 'registryFixture', wire: 'request', codec: { mode: 'src-json' }, + }, + }, 'repeats wire field'], + [{ + ...invocation(), + result: { mode: 'strict', typeSymbol: '', schema: z.string() }, + }, 'type symbol'], + [{ + ...invocation(), + result: { mode: 'strict', typeSymbol: '@fixture#Broken', schema: {} as z.ZodType }, + }, 'has no parse'], + ] + for (const [index, [descriptor, message]] of malformed.entries()) { + expect(() => ctx.typert.remotes.register({ + package: `@fixture/malformed-${String(index)}`, + descriptors: [descriptor], + })).toThrow(message) + } + + expect(() => ctx.typert.lookups.register('bad#key' as 'fixture', { + parameter: 'agent', + wire: 'agent/id', + hostTypeSymbol: '', + wireTypeSymbol: '', + resolve: () => undefined, + })).toThrow('lookup key') + expect(() => ctx.typert.lookups.register('fixture', { + parameter: 'agent', + wire: 'agent/id', + hostTypeSymbol: '@fixture#Agent', + wireTypeSymbol: '@fixture#AgentId', + resolve: () => undefined, + })).toThrow('lookup wire field') + }) + + it('installs the registry through the Client entry without importing the Host entry', async () => { + const ctx = new Context() + await ctx.plugin({ inject: clientRegistryInject, apply: applyClientRegistry }) + expect(ctx.typert.list()).toEqual([]) + }) + it('contains change-listener failures and still notifies later listeners', async () => { const ctx = await makeCtx() const warnings: unknown[] = [] diff --git a/packages/typert/type-meta/package.json b/packages/typert/type-meta/package.json index 2ffcd6c5ed..e2d7689866 100644 --- a/packages/typert/type-meta/package.json +++ b/packages/typert/type-meta/package.json @@ -26,9 +26,7 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/typert/type-meta/tests/type-meta.spec.ts b/packages/typert/type-meta/tests/type-meta.spec.ts index 1eab5a6ca3..f25c367914 100644 --- a/packages/typert/type-meta/tests/type-meta.spec.ts +++ b/packages/typert/type-meta/tests/type-meta.spec.ts @@ -107,6 +107,80 @@ describe('type-meta Remote declarations', () => { expect(remoteMethods(first)).toEqual([{ method: 'run', invocation: { kind: 'direct' } }]) }) + it('supports explicit export names without exposing marker storage', () => { + class Service { + run(value: string): string { + return value + } + + scoped(value: string): string { + return value + } + } + const initializers: Array<(this: Service) => void> = [] + Remote('execute')( + Reflect.get(Service.prototype, 'run') as (this: Service, ...args: unknown[]) => unknown, + methodContext('run', initializers), + ) + RemoteContext('metaFixture', 'inspect')( + Reflect.get(Service.prototype, 'scoped') as (this: Service, ...args: unknown[]) => unknown, + methodContext('scoped', initializers), + ) + const service = new Service() + for (const initialize of initializers) initialize.call(service) + + expect(remoteMethods(service)).toEqual([ + { method: 'run', exportName: 'execute', invocation: { kind: 'direct' } }, + { method: 'scoped', exportName: 'inspect', invocation: { kind: 'context', context: 'metaFixture' } }, + ]) + expect(remoteMethods({})).toEqual([]) + const prototypeLess: object = {} + Reflect.setPrototypeOf(prototypeLess, null) + expect(remoteMethods(prototypeLess)).toEqual([]) + }) + + it('rejects malformed decorator calls and targets', () => { + const method: (this: object) => void = function (this: object): void {} + expect(() => { (Remote as unknown as (value: typeof method) => void)(method) }).toThrow('context is missing') + expect(() => Remote('bad/name')).toThrow('export name') + expect(() => RemoteContext('' as 'metaFixture')).toThrow('Context key') + expect(() => RemoteContext('metaFixture', 'bad/name')).toThrow('export name') + + for (const context of [ + { ...methodContext('run', []), private: true }, + { ...methodContext('run', []), static: true }, + { ...methodContext('run', []), name: Symbol('run') }, + ]) { + expect(() => { Remote(method, context) }) + .toThrow('public instance method') + } + }) + + it('rejects prototype-less initialization and conflicting markers', () => { + const method: (this: object) => void = function (this: object): void {} + const direct: Array<(this: object) => void> = [] + Remote(method, methodContext('run', direct)) + const prototypeLess: object = {} + Reflect.setPrototypeOf(prototypeLess, null) + expect(() => { direct[0]!.call(prototypeLess) }).toThrow('without a prototype') + + class Service { + run(): void {} + } + const conflicting: Array<(this: Service) => void> = [] + Remote( + Reflect.get(Service.prototype, 'run'), + methodContext('run', conflicting), + ) + RemoteContext('metaFixture')( + Reflect.get(Service.prototype, 'run'), + methodContext('run', conflicting), + ) + const service = new Service() + conflicting[0]!.call(service) + expect(() => { conflicting[1]!.call(service) }).toThrow('conflicting invocation markers') + }) + it('rejects ambiguous binding names', () => { expect(() => bindTypeRTGateway({}, '')).toThrow('service key') expect(() => bindTypeRTGateway({}, 'goals', { namespace: 'api/goals' })).toThrow('namespace') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 45e8ad1803..caf1f8a5ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7077,6 +7077,9 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../packages/core/tools + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../../packages/typert/type-meta '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../../packages/ui/user-approval diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 4fd7291633..d8151de3a4 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -93,6 +93,7 @@ "@deepseek-ai/dsh-tool-web": "workspace:^", "@deepseek-ai/dsh-tool-workflow": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index e0b9344cdf..9be97f53e6 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -7,7 +7,7 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs' import { join, relative, resolve } from 'node:path' -import { isForbiddenPublicationFile } from './publication-payload.ts' +import { hasTypeRTRemoteNavigation, isForbiddenPublicationFile } from './publication-payload.ts' const root = resolve(import.meta.dirname, '..') // vendor/* is single-level; packages// nests one level deeper @@ -122,6 +122,7 @@ function sameStringList(actual: readonly string[] | undefined, expected: readonl function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] { const extras = manifest.name ? packageFileExtras[manifest.name] ?? [] : [] + const typeRTRemoteNavigation = hasTypeRTRemoteNavigation(manifest) return [ 'lib/index.js', // Every package publishes its invariant ownership companion as a separate @@ -145,9 +146,37 @@ function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] { // declarations. ...usesEmittedTreeDefaults(manifest) ? ['lib/types/**/*.js'] : [], 'lib/types/**/*.d.ts', + ...hasExportPair(manifest, './typert', './lib/typert.host.d.ts', './lib/typert.host.js') + ? ['lib/typert.host.js', 'lib/typert.host.d.ts'] + : [], + ...hasExportPair(manifest, './client/typert', './lib/typert.client.d.ts', './lib/typert.client.js') + ? ['lib/typert.client.js', 'lib/typert.client.d.ts'] + : [], + ...typeRTRemoteNavigation + ? [ + 'lib/typert.remote-client.js', + 'lib/typert.remote-client.d.ts', + 'lib/typert.remote-client.d.ts.map', + 'src', + ] + : [], ] } +/** Whether one conditional export exactly names the generated runtime and declaration pair. */ +function hasExportPair( + manifest: PackageManifest, + subpath: string, + types: string, + runtime: string, +): boolean { + const entry = manifest.exports?.[subpath] + return typeof entry === 'object' + && entry !== null + && entry.types === types + && entry.default === runtime +} + /** Runtime target of an export entry: conditional `default`, or the bare-string shorthand. */ function exportDefault(manifest: PackageManifest, subpath: string): string | undefined { const entry = manifest.exports?.[subpath] @@ -175,8 +204,9 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] { } if (manifest.name?.startsWith('@deepseek-ai/')) { + const publicationPolicy = { typeRTRemoteNavigation: hasTypeRTRemoteNavigation(manifest) } for (const file of manifest.files ?? []) { - if (isForbiddenPublicationFile(file)) { + if (isForbiddenPublicationFile(file, publicationPolicy)) { errors.push(`${label}: package.json files must not publish ${JSON.stringify(file)}`) } } diff --git a/scripts/dev-web.spec.ts b/scripts/dev-web.spec.ts index 2edbc652ab..71576caf9e 100644 --- a/scripts/dev-web.spec.ts +++ b/scripts/dev-web.spec.ts @@ -22,13 +22,7 @@ export default defineConfig({ const bundlePath = join(root, 'lib/client.js') await writeFile(sourcePath, 'export const version = "watch-v1"\n') bundles = await watchClientPlugins(root, ['.'], 50) - await expect.poll(async () => { - try { - return (await readFile(bundlePath, 'utf8')).includes('watch-v1') - } catch { - return false - } - }, { timeout: 10_000 }).toBe(true) + expect(await readFile(bundlePath, 'utf8')).toContain('watch-v1') await new Promise(resolve => setTimeout(resolve, 1_000)) await writeFile(sourcePath, `export const version = "watch-v2-${'x'.repeat(100)}"\n`) diff --git a/scripts/dev-web.ts b/scripts/dev-web.ts index aee7146487..294e38002b 100644 --- a/scripts/dev-web.ts +++ b/scripts/dev-web.ts @@ -47,21 +47,40 @@ export function discoverPluginDirs(root = repoRoot): string[] { * @param root - repository or fixture root passed to tsdown. * @param pluginDirs - workspace-relative package directories to watch. * @param pollInterval - optional source-watcher polling interval in milliseconds. - * @returns live bundles whose async disposers stop every watcher. + * @returns live bundles after every watcher has completed its initial build. */ export async function watchClientPlugins( root: string, pluginDirs: readonly string[], pollInterval?: number, ): Promise { - return build({ + let resolveInitialBuilds: (() => void) | undefined + const initialBuilds = new Promise((resolve) => { resolveInitialBuilds = resolve }) + const initialized = new WeakSet() + const readiness: { expectedBuilds?: number; initializedBuilds: number } = { initializedBuilds: 0 } + const bundles = await build({ cwd: root, workspace: [...pluginDirs], watch: true, + hooks: { + 'build:done': ({ options }) => { + if (initialized.has(options)) return + initialized.add(options) + readiness.initializedBuilds += 1 + if ( + readiness.expectedBuilds !== undefined + && readiness.initializedBuilds >= readiness.expectedBuilds + ) resolveInitialBuilds?.() + }, + }, ...pollInterval !== undefined ? { inputOptions: { watch: { watcher: { usePolling: true, pollInterval } } } } : {}, }) + readiness.expectedBuilds = bundles.length + if (readiness.initializedBuilds >= readiness.expectedBuilds) resolveInitialBuilds?.() + await initialBuilds + return bundles } const invokedPath = process.argv[1] diff --git a/scripts/publication-payload.spec.ts b/scripts/publication-payload.spec.ts index 0f403bee7c..03603ef54d 100644 --- a/scripts/publication-payload.spec.ts +++ b/scripts/publication-payload.spec.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { isForbiddenPublicationFile, validateTarballPayload } from './publication-payload.ts' +import { + hasTypeRTRemoteNavigation, + isForbiddenPublicationFile, + validateTarballPayload, +} from './publication-payload.ts' function validateFixtureTarball(files: readonly string[]): () => void { return () => { @@ -51,4 +55,29 @@ describe('publication payload policy', () => { 'package/lib/styles/base.css', ])).not.toThrow() }) + + it('allows only the TypeRT declaration map and its navigable source tree when requested', () => { + const policy = { typeRTRemoteNavigation: true } + expect(isForbiddenPublicationFile('src/index.ts', policy)).toBe(false) + expect(isForbiddenPublicationFile('lib/typert.remote-client.d.ts.map', policy)).toBe(false) + expect(isForbiddenPublicationFile('lib/types/index.d.ts.map', policy)).toBe(true) + expect(() => { + validateTarballPayload([ + 'package/lib/typert.remote-client.d.ts.map', + 'package/src/index.ts', + ], 'fixture.tgz', policy) + }).not.toThrow() + }) + + it('recognizes only the canonical Host-for-Client export pair', () => { + expect(hasTypeRTRemoteNavigation({ + exports: { + './remote': { + types: './lib/typert.remote-client.d.ts', + default: './lib/typert.remote-client.js', + }, + }, + })).toBe(true) + expect(hasTypeRTRemoteNavigation({ exports: { './remote': './lib/remote.js' } })).toBe(false) + }) }) diff --git a/scripts/publication-payload.ts b/scripts/publication-payload.ts index 9c16067fe7..60f37b4f94 100644 --- a/scripts/publication-payload.ts +++ b/scripts/publication-payload.ts @@ -1,5 +1,22 @@ /** Publication payload policy shared by static manifests and packed tarballs. */ +/** Publication exceptions required for TypeRT declaration-map navigation. */ +export interface PublicationPayloadPolicy { + readonly typeRTRemoteNavigation?: boolean +} + +/** Whether a package manifest exports generated Host-for-Client metadata with source navigation. */ +export function hasTypeRTRemoteNavigation(manifest: unknown): boolean { + if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest)) return false + const exportsField = (manifest as Record).exports + if (exportsField === null || typeof exportsField !== 'object' || Array.isArray(exportsField)) return false + const remote = (exportsField as Record)['./remote'] + if (remote === null || typeof remote !== 'object' || Array.isArray(remote)) return false + const entry = remote as Record + return entry.types === './lib/typert.remote-client.d.ts' + && entry.default === './lib/typert.remote-client.js' +} + /** Normalize a package manifest path or npm tarball member to its payload-relative path. */ function payloadPath(file: string): string { const normalized = file.replaceAll('\\', '/').replace(/^\.\/+/, '').replace(/\/+$/, '') @@ -7,17 +24,30 @@ function payloadPath(file: string): string { } /** Whether a package payload path exposes source or declaration-map intermediates. */ -export function isForbiddenPublicationFile(file: string): boolean { +export function isForbiddenPublicationFile( + file: string, + policy: PublicationPayloadPolicy = {}, +): boolean { const normalized = payloadPath(file) + if (policy.typeRTRemoteNavigation === true + && (normalized === 'src' + || normalized.startsWith('src/') + || normalized === 'lib/typert.remote-client.d.ts.map')) { + return false + } return normalized === 'src' || normalized.startsWith('src/') || normalized.endsWith('.d.ts.map') } /** Reject source and declaration-map members in a packed npm tarball. */ -export function validateTarballPayload(files: readonly string[], context: string): void { +export function validateTarballPayload( + files: readonly string[], + context: string, + policy: PublicationPayloadPolicy = {}, +): void { for (const file of files) { - if (!isForbiddenPublicationFile(file)) continue + if (!isForbiddenPublicationFile(file, policy)) continue const normalized = payloadPath(file) if (normalized === 'src' || normalized.startsWith('src/')) { throw new Error(`${context} publishes source file ${file}`) diff --git a/scripts/publish-npm-baseline.ts b/scripts/publish-npm-baseline.ts index 20b9eeaea0..4a33f32e1e 100644 --- a/scripts/publish-npm-baseline.ts +++ b/scripts/publish-npm-baseline.ts @@ -18,7 +18,7 @@ import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep import { createInterface } from 'node:readline/promises' import { pathToFileURL } from 'node:url' import { parseArgs } from 'node:util' -import { validateTarballPayload } from './publication-payload.ts' +import { hasTypeRTRemoteNavigation, validateTarballPayload } from './publication-payload.ts' const DEFAULT_REGISTRY = 'https://registry.npm.harnessment.com' const DEFAULT_OUTPUT_DIRECTORY = '.artifacts/npm-baseline' @@ -320,7 +320,11 @@ class ReleaseBundle { if (expected === undefined || !missingNames.delete(artifact.name)) { throw new Error(`unexpected or duplicate packed package: ${artifact.name}`) } - if (expected.origin === 'harness') validateTarballPayload(artifact.files, tarball) + if (expected.origin === 'harness') { + validateTarballPayload(artifact.files, tarball, { + typeRTRemoteNavigation: hasTypeRTRemoteNavigation(artifact.manifest), + }) + } if (artifact.version !== version) { throw new Error(`${tarball} has version ${artifact.version}; expected ${version}`) } @@ -394,7 +398,11 @@ class ReleaseBundle { throw new Error(`tarball checksum mismatch: ${pkg.tarball}`) } const artifact = inspectTarball(path, runner) - if (pkg.origin === 'harness') validateTarballPayload(artifact.files, pkg.tarball) + if (pkg.origin === 'harness') { + validateTarballPayload(artifact.files, pkg.tarball, { + typeRTRemoteNavigation: hasTypeRTRemoteNavigation(artifact.manifest), + }) + } if (artifact.name !== pkg.name || artifact.version !== this.manifest.version) { throw new Error(`tarball identity mismatch: ${pkg.tarball}`) } From 41677c3be00557a2a741e03bbf6419956ca0c68e Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 06:02:12 +0800 Subject: [PATCH 060/104] fix(ci): preserve TypeRT contract build order on Windows --- scripts/wine-windows-gates.sh | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/scripts/wine-windows-gates.sh b/scripts/wine-windows-gates.sh index 7706a88a4b..1f5de1dcbd 100755 --- a/scripts/wine-windows-gates.sh +++ b/scripts/wine-windows-gates.sh @@ -204,11 +204,14 @@ cat "$scratch/logs/smoke.log" grep -q '^smoke: win32 x64' "$scratch/logs/smoke.log" || { echo 'wine-windows-gates: Windows Node smoke did not report win32 x64' >&2; exit 1; } # ---- the two blocking surfaces, concurrently ------------------------------ -# The same shape run-gates gives ci-windows-blocking on native Windows: -# `build` = tsc -b then tsdown, `production site` = the VitePress build. Both -# statuses are captured so one failure cannot hide the other's result. +# The build preserves the face order from package.json: generate Host contracts +# before either aggregate typecheck, then bundle the completed workspace. +# Both statuses are captured so one failure cannot hide the other's result. build_gate() { - wine_node "$scratch/logs/tsc.log" "$tsc_js" -b --pretty false || return $? + wine_node "$scratch/logs/contracts-tsc.log" "$tsc_js" -b packages/typert/generator --pretty false || return $? + wine_node "$scratch/logs/contracts-tsdown.log" "$tsdown_js" --config tsdown.typert-host.config.ts || return $? + wine_node "$scratch/logs/host-tsc.log" "$tsc_js" -b tsconfig.host.json --pretty false || return $? + wine_node "$scratch/logs/client-tsc.log" "$tsc_js" -b tsconfig.client.json --pretty false || return $? wine_node "$scratch/logs/tsdown.log" "$tsdown_js" } site_gate() { @@ -235,7 +238,12 @@ report() { for log in "$@"; do tail -n 200 "$log" >&2 || true; done fi } -report 'build (tsc -b, tsdown)' "$build_status" "$scratch/logs/tsc.log" "$scratch/logs/tsdown.log" +report 'build (contract prepass, tsc, tsdown)' "$build_status" \ + "$scratch/logs/contracts-tsc.log" \ + "$scratch/logs/contracts-tsdown.log" \ + "$scratch/logs/host-tsc.log" \ + "$scratch/logs/client-tsc.log" \ + "$scratch/logs/tsdown.log" report 'production site (vitepress build)' "$site_status" "$scratch/logs/site.log" if (( build_status != 0 )); then exit "$build_status"; fi exit "$site_status" From 61c2c15dc46187ec44a8737781e38e656233352c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:02:10 +0800 Subject: [PATCH 061/104] refactor(goal): own direct goal operations --- packages/goal/goal/src/domain.ts | 29 +------------ packages/goal/goal/src/index.ts | 71 ++++++++++++++++++++++++++++++-- packages/goal/goal/src/types.ts | 32 ++++++++++++++ 3 files changed, 100 insertions(+), 32 deletions(-) diff --git a/packages/goal/goal/src/domain.ts b/packages/goal/goal/src/domain.ts index fec44de2f3..8c8c4de6b6 100644 --- a/packages/goal/goal/src/domain.ts +++ b/packages/goal/goal/src/domain.ts @@ -8,22 +8,7 @@ */ import type { Agent } from '@deepseek-ai/dsh-agent' -import type { GoalId, GoalRef, GoalSnapshot } from './types.ts' - -/** Whether this live process may automatically continue an active goal. */ -export type GoalActivation = 'armed' | 'disarmed' - -/** Current goal projection, including values derived from the session log. */ -export interface GoalView extends GoalSnapshot { - /** Highest admitted round number for this goal. */ - readonly roundsStarted: number - /** Epoch milliseconds of the create mutation. */ - readonly createdAt: number - /** Epoch milliseconds of the latest mutation. */ - readonly updatedAt: number - /** Process-local continuation eligibility; never persisted. */ - readonly activation: GoalActivation -} +import type { GoalId, GoalRef, GoalSnapshot, GoalView } from './types.ts' /** Goal state-changing verbs recorded in the durable source change. */ export type GoalOperation = @@ -96,18 +81,6 @@ export interface FoldedGoal { readonly lastRef?: GoalRef } -/** Input whose omitted round cap is resolved by the service configuration. */ -export interface CreateGoalRequest { - readonly objective: string - readonly maxGoalRounds?: number -} - -/** Fields changed by an edit; at least one must be present. */ -export interface EditGoalRequest { - readonly objective?: string - readonly maxGoalRounds?: number -} - /** Live notification after one durable goal mutation commits. */ export interface GoalChanged { readonly operation: GoalOperation diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index 1cd3c6074a..87ea52018e 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -27,22 +27,23 @@ import { GoalId, } from './runtime.ts' import type { + CreateGoalRequest, + CreateGoalResult, + EditGoalRequest, + GoalActivation, GoalBlockReason, GoalPhase, GoalProjection, GoalRef, GoalSnapshot, + GoalView, } from './types.ts' import type { - CreateGoalRequest, - EditGoalRequest, - GoalActivation, GoalChangeMeta, GoalChanged, GoalClearChangeMeta, GoalOperation, GoalSnapshotChangeMeta, - GoalView, } from './domain.ts' // The pure payload outlet (./types.ts, ONE home of the `goal` projection-key @@ -568,6 +569,68 @@ export class GoalService extends Service { activation: cache.activation, } } + + /** + * Create one Goal through the remote boundary. + * @param agent - exact live Agent resolved from the wire identity. + * @param request - objective and optional round cap. + * @returns the created Goal identity. + */ + remoteExportCreate(agent: Agent, request: CreateGoalRequest): CreateGoalResult { + const view = this.create(agent, request) + return { ref: { id: view.id, revision: view.revision } } + } + + /** + * Edit one Goal through the remote boundary. + * @param agent - exact live Agent resolved from the wire identity. + * @param ref - expected current revision. + * @param request - replacement fields. + * @returns the edited Goal view. + */ + remoteExportEdit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView { + return this.edit(agent, ref, request) + } + + /** + * Pause one Goal through the remote boundary. + * @param agent - exact live Agent resolved from the wire identity. + * @param ref - expected current revision. + * @returns the paused Goal view. + */ + remoteExportPause(agent: Agent, ref: GoalRef): GoalView { + return this.pause(agent, ref) + } + + /** + * Resume one Goal through the remote boundary. + * @param agent - exact live Agent resolved from the wire identity. + * @param ref - expected current revision. + * @returns the resumed Goal view. + */ + remoteExportResume(agent: Agent, ref: GoalRef): GoalView { + return this.resume(agent, ref) + } + + /** + * Complete one Goal through the remote boundary. + * @param agent - exact live Agent resolved from the wire identity. + * @param ref - expected current revision. + * @returns the completed Goal view. + */ + remoteExportComplete(agent: Agent, ref: GoalRef): GoalView { + return this.complete(agent, ref) + } + + /** + * Clear one terminal Goal through the remote boundary. + * @param agent - exact live Agent resolved from the wire identity. + * @param ref - expected current revision. + * @returns the committed clear revision. + */ + remoteExportClear(agent: Agent, ref: GoalRef): GoalRef { + return this.clear(agent, ref) + } } export default GoalService diff --git a/packages/goal/goal/src/types.ts b/packages/goal/goal/src/types.ts index 25e22bd5b2..f277e8620c 100644 --- a/packages/goal/goal/src/types.ts +++ b/packages/goal/goal/src/types.ts @@ -23,6 +23,23 @@ export interface GoalRef { readonly revision: number } +/** Input whose omitted round cap is resolved by the service configuration. */ +export interface CreateGoalRequest { + readonly objective: string + readonly maxGoalRounds?: number +} + +/** Wire-safe acknowledgement of one created goal. */ +export interface CreateGoalResult { + readonly ref: GoalRef +} + +/** Fields changed by an edit; at least one must be present. */ +export interface EditGoalRequest { + readonly objective?: string + readonly maxGoalRounds?: number +} + /** Durable continuation phase. Activation is process-local and separate. */ export type GoalPhase = | 'active' @@ -50,6 +67,21 @@ export interface GoalSnapshot extends GoalRef { readonly maxGoalRounds: number } +/** Whether this live process may automatically continue an active goal. */ +export type GoalActivation = 'armed' | 'disarmed' + +/** Current goal projection, including values derived from the session log. */ +export interface GoalView extends GoalSnapshot { + /** Highest admitted round number for this goal. */ + readonly roundsStarted: number + /** Epoch milliseconds of the create mutation. */ + readonly createdAt: number + /** Epoch milliseconds of the latest mutation. */ + readonly updatedAt: number + /** Process-local continuation eligibility; never persisted. */ + readonly activation: GoalActivation +} + /** * The `goal` projection value: the current durable goal with its replay * counters, exactly as the latest `goal/change` event carried them. From 9400926bdfe8320726e64664a36a3cbde6b21b59 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:28:54 +0800 Subject: [PATCH 062/104] feat(goal): add TypeRT gateway example --- docs/config-catalog.md | 3 +- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 53 ++- docs/event-producer-consumer.md | 2 +- docs/module-graph.md | 312 +++++++++++------- docs/persistence-catalog.md | 2 +- knip.json | 9 + packages/bundle/web-app/cordis.patch.yml | 3 + packages/bundle/web-app/package.json | 1 + packages/client/remotes/README.i18n.yaml | 6 + packages/client/remotes/README.md | 22 ++ packages/client/remotes/README.zh.md | 22 ++ packages/client/remotes/package.json | 55 +++ packages/client/remotes/src/client/index.ts | 19 ++ packages/client/remotes/src/index.ts | 4 + packages/client/remotes/src/invariant.ts | 24 ++ .../client/remotes/tests/built-lib.e2e.ts | 214 ++++++++++++ packages/client/remotes/tsconfig.json | 30 ++ packages/client/remotes/tsdown.config.ts | 3 + packages/client/runtime/package.json | 10 +- .../client/runtime/src/client/agents/scope.ts | 18 +- .../runtime/src/client/contract/sessions.ts | 6 +- packages/client/runtime/src/client/index.ts | 18 +- .../runtime/src/client/sessions/service.ts | 8 +- .../client/runtime/tests/client-apply.spec.ts | 3 + packages/client/runtime/tsconfig.json | 9 + packages/client/test-runtime/src/sessions.ts | 8 +- .../cordis/tool-cordis/src/api-catalog.ts | 28 ++ packages/goal/goal/package.json | 18 +- packages/goal/goal/src/index.ts | 10 + packages/goal/goal/tsconfig.json | 3 + pnpm-lock.yaml | 30 ++ scripts/gen-cordis-catalog.ts | 1 + scripts/type-equiv.manifest.json | 6 +- .../verify-package-readme-model-experience.ts | 1 + tsconfig.client.json | 1 + 36 files changed, 809 insertions(+), 155 deletions(-) create mode 100644 packages/client/remotes/README.i18n.yaml create mode 100644 packages/client/remotes/README.md create mode 100644 packages/client/remotes/README.zh.md create mode 100644 packages/client/remotes/package.json create mode 100644 packages/client/remotes/src/client/index.ts create mode 100644 packages/client/remotes/src/index.ts create mode 100644 packages/client/remotes/src/invariant.ts create mode 100644 packages/client/remotes/tests/built-lib.e2e.ts create mode 100644 packages/client/remotes/tsconfig.json create mode 100644 packages/client/remotes/tsdown.config.ts diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 5728ac4bed..08f26479ca 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -495,7 +495,7 @@ export interface Config { } ``` -Source: [`packages/goal/goal/src/index.ts:114`](../packages/goal/goal/src/index.ts) +Source: [`packages/goal/goal/src/index.ts:116`](../packages/goal/goal/src/index.ts) ## `@deepseek-ai/dsh-headless` @@ -2522,6 +2522,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) - `@deepseek-ai/dsh-client-locale` ([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) - `@deepseek-ai/dsh-client-modules` — requires `httpServer` · `loader` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) +- `@deepseek-ai/dsh-client-remotes` ([`packages/client/remotes/src/index.ts`](../packages/client/remotes/src/index.ts)) - `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts)) - `@deepseek-ai/dsh-client-ui-command` ([`packages/client/ui-command/src/index.ts`](../packages/client/ui-command/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 348d334e9f..4ad9797262 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -472,7 +472,7 @@ Goal mutation accepted by one live agent. The matching `goal/change` session eve Types: [Agent](../core-data-structures/core.md) · [GoalChanged](../core-data-structures/goal.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/goal/goal/src/domain.ts:141`](../../packages/goal/goal/src/domain.ts) +Source: [`packages/goal/goal/src/domain.ts:114`](../../packages/goal/goal/src/domain.ts) ## `llm/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 41059aebf4..9f5e66ea36 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -761,11 +761,60 @@ block(agent: Agent, ref: GoalRef, reason: GoalBlockReason): GoalView * @returns the tombstone ref whose revision is one past the cleared snapshot. */ clear(agent: Agent, ref: GoalRef): GoalRef + +/** + * Create one Goal through the remote boundary. + * @param agent - exact live Agent resolved from the wire identity. + * @param request - objective and optional round cap. + * @returns the created Goal identity. + */ +@Remote('create') remoteExportCreate(agent: Agent, request: CreateGoalRequest): CreateGoalResult + +/** + * Edit one Goal through the remote boundary. + * @param agent - exact live Agent resolved from the wire identity. + * @param ref - expected current revision. + * @param request - replacement fields. + * @returns the edited Goal view. + */ +@Remote('edit') remoteExportEdit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView + +/** + * Pause one Goal through the remote boundary. + * @param agent - exact live Agent resolved from the wire identity. + * @param ref - expected current revision. + * @returns the paused Goal view. + */ +@Remote('pause') remoteExportPause(agent: Agent, ref: GoalRef): GoalView + +/** + * Resume one Goal through the remote boundary. + * @param agent - exact live Agent resolved from the wire identity. + * @param ref - expected current revision. + * @returns the resumed Goal view. + */ +@Remote('resume') remoteExportResume(agent: Agent, ref: GoalRef): GoalView + +/** + * Complete one Goal through the remote boundary. + * @param agent - exact live Agent resolved from the wire identity. + * @param ref - expected current revision. + * @returns the completed Goal view. + */ +@Remote('complete') remoteExportComplete(agent: Agent, ref: GoalRef): GoalView + +/** + * Clear one terminal Goal through the remote boundary. + * @param agent - exact live Agent resolved from the wire identity. + * @param ref - expected current revision. + * @returns the committed clear revision. + */ +@Remote('clear') remoteExportClear(agent: Agent, ref: GoalRef): GoalRef ``` -Types: [Agent](../core-data-structures/core.md) · [CreateGoalRequest](../core-data-structures/goal.md) · [EditGoalRequest](../core-data-structures/goal.md) · [GoalBlockReason](../core-data-structures/goal.md) · [GoalRef](../core-data-structures/goal.md) · [GoalView](../core-data-structures/goal.md) +Types: [Agent](../core-data-structures/core.md) · [CreateGoalRequest](../core-data-structures/goal.md) · [CreateGoalResult](../core-data-structures/goal.md) · [EditGoalRequest](../core-data-structures/goal.md) · [GoalBlockReason](../core-data-structures/goal.md) · [GoalRef](../core-data-structures/goal.md) · [GoalView](../core-data-structures/goal.md) -Source: [`packages/goal/goal/src/index.ts:181`](../../packages/goal/goal/src/index.ts) +Source: [`packages/goal/goal/src/index.ts:183`](../../packages/goal/goal/src/index.ts) ## `ctx.httpServer` — `HttpServerService` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 286c0ee4c2..f5b3a0b99a 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -27,7 +27,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../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-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-policy`](../packages/fs/fs-policy), [`skill-local`](../packages/skill/skill-local) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../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-policy`](../packages/fs/fs-policy) | -| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:141`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | +| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:73`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:62`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:73`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | diff --git a/docs/module-graph.md b/docs/module-graph.md index fd9ac036a0..f659e61206 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -156,6 +156,7 @@ flowchart TD pkg_client_hmr["client-hmr"] pkg_client_locale["client-locale"] pkg_client_modules["client-modules"] + pkg_client_remotes["client-remotes"] pkg_client_runtime["client-runtime"] pkg_client_schema_form["client-schema-form"] pkg_client_test_runtime["client-test-runtime"] @@ -300,7 +301,6 @@ flowchart TD pkg_loader_smoke --> pkg_invariants pkg_base --> pkg_invariants pkg_client_modules --> pkg_invariants - pkg_client_runtime --> pkg_invariants pkg_client_schema_form --> pkg_invariants pkg_client_ui_primitives --> pkg_invariants pkg_client_ui_slots --> pkg_invariants @@ -324,22 +324,6 @@ flowchart TD pkg_client_hmr --> pkg_client_modules pkg_client_hmr --> pkg_host_webserver pkg_client_hmr --> pkg_invariants - pkg_client_locale --> pkg_client_runtime - pkg_client_locale --> pkg_client_ui_primitives - pkg_client_locale --> pkg_client_ui_slots - pkg_client_locale --> pkg_invariants - pkg_client_test_runtime --> pkg_client_runtime - pkg_client_test_runtime --> pkg_client_ui_slots - pkg_client_test_runtime --> pkg_client_web_react - pkg_client_test_runtime --> pkg_host_apiproxy - pkg_client_test_runtime --> pkg_invariants - pkg_client_ui_settings --> pkg_client_runtime - pkg_client_ui_settings --> pkg_client_ui_primitives - pkg_client_ui_settings --> pkg_client_ui_slots - pkg_client_ui_settings --> pkg_invariants - pkg_client_ui_trajectory --> pkg_client_runtime - pkg_client_ui_trajectory --> pkg_client_ui_primitives - pkg_client_ui_trajectory --> pkg_invariants pkg_credentials --> pkg_brand pkg_credentials --> pkg_invariants pkg_frontend_static --> pkg_host_webserver @@ -383,43 +367,6 @@ flowchart TD pkg_system_prompt --> pkg_scope pkg_web --> pkg_invariants pkg_web --> pkg_llm - pkg_client_ui_models --> pkg_client_connection - pkg_client_ui_models --> pkg_client_runtime - pkg_client_ui_models --> pkg_client_schema_form - pkg_client_ui_models --> pkg_client_ui_primitives - pkg_client_ui_models --> pkg_client_ui_slots - pkg_client_ui_models --> pkg_client_web_react - pkg_client_ui_models --> pkg_invariants - pkg_client_ui_question --> pkg_client_locale - pkg_client_ui_question --> pkg_invariants - pkg_client_ui_settings_general --> pkg_client_connection - pkg_client_ui_settings_general --> pkg_client_locale - pkg_client_ui_settings_general --> pkg_client_runtime - pkg_client_ui_settings_general --> pkg_client_ui_primitives - pkg_client_ui_settings_general --> pkg_client_ui_settings - pkg_client_ui_settings_general --> pkg_client_ui_slots - pkg_client_ui_settings_general --> pkg_client_web_react - pkg_client_ui_settings_general --> pkg_invariants - pkg_client_ui_sidebar --> pkg_client_locale - pkg_client_ui_sidebar --> pkg_client_runtime - pkg_client_ui_sidebar --> pkg_client_ui_primitives - pkg_client_ui_sidebar --> pkg_client_ui_slots - pkg_client_ui_sidebar --> pkg_invariants - pkg_client_ui_slash --> pkg_client_locale - pkg_client_ui_slash --> pkg_client_runtime - pkg_client_ui_slash --> pkg_client_ui_primitives - pkg_client_ui_slash --> pkg_client_ui_slots - pkg_client_ui_slash --> pkg_invariants - pkg_client_ui_theme --> pkg_client_locale - pkg_client_ui_theme --> pkg_client_runtime - pkg_client_ui_theme --> pkg_client_ui_primitives - pkg_client_ui_theme --> pkg_client_ui_slots - pkg_client_ui_theme --> pkg_invariants - pkg_client_ui_workspace --> pkg_client_locale - pkg_client_ui_workspace --> pkg_client_runtime - pkg_client_ui_workspace --> pkg_client_ui_primitives - pkg_client_ui_workspace --> pkg_client_ui_slots - pkg_client_ui_workspace --> pkg_invariants pkg_credentials_local --> pkg_atomic_write pkg_credentials_local --> pkg_credentials pkg_credentials_local --> pkg_invariants @@ -472,24 +419,17 @@ flowchart TD pkg_app_boot --> pkg_invariants pkg_app_boot --> pkg_paths pkg_app_boot --> pkg_system_prompt +<<<<<<< HEAD pkg_client_ui_layout --> pkg_client_runtime pkg_client_ui_layout --> pkg_client_ui_slots pkg_client_ui_layout --> pkg_client_ui_theme pkg_client_ui_layout --> pkg_invariants +======= +>>>>>>> d6e02ebbcc (feat(goal): add TypeRT gateway example) pkg_code_runtime_worker --> pkg_code_runtime pkg_code_runtime_worker --> pkg_invariants pkg_code_runtime_worker --> pkg_session pkg_code_runtime_worker --> pkg_timeout - pkg_host_directory_picker_browse --> pkg_client_locale - pkg_host_directory_picker_browse --> pkg_client_runtime - pkg_host_directory_picker_browse --> pkg_client_ui_primitives - pkg_host_directory_picker_browse --> pkg_client_ui_slots - pkg_host_directory_picker_browse --> pkg_client_ui_workspace - pkg_host_directory_picker_browse --> pkg_invariants - pkg_host_directory_picker_native --> pkg_client_runtime - pkg_host_directory_picker_native --> pkg_client_ui_slots - pkg_host_directory_picker_native --> pkg_client_ui_workspace - pkg_host_directory_picker_native --> pkg_invariants pkg_lsp_local --> pkg_brand pkg_lsp_local --> pkg_invariants pkg_lsp_local --> pkg_llm @@ -518,6 +458,7 @@ flowchart TD pkg_goal --> pkg_scope pkg_goal --> pkg_session pkg_goal --> pkg_session_projection + pkg_goal --> pkg_type_meta pkg_bash_local --> pkg_bash pkg_bash_local --> pkg_invariants pkg_bash_local --> pkg_subprocess @@ -582,10 +523,6 @@ flowchart TD pkg_tmux_context --> pkg_bash pkg_tmux_context --> pkg_invariants pkg_tmux_context --> pkg_session - pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse - 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_pty --> pkg_agent pkg_pty --> pkg_brand pkg_pty --> pkg_invariants @@ -677,6 +614,7 @@ flowchart TD pkg_permission --> pkg_session_projection pkg_permission --> pkg_settings pkg_permission --> pkg_user_approval +<<<<<<< HEAD pkg_client_ui_conversation --> pkg_client_locale pkg_client_ui_conversation --> pkg_client_runtime pkg_client_ui_conversation --> pkg_client_ui_primitives @@ -687,6 +625,11 @@ flowchart TD pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session +======= + pkg_client_remotes --> pkg_goal + pkg_client_remotes --> pkg_host_api_gateway + pkg_client_remotes --> pkg_invariants +>>>>>>> d6e02ebbcc (feat(goal): add TypeRT gateway example) pkg_pty_local --> pkg_agent pkg_pty_local --> pkg_invariants pkg_pty_local --> pkg_pty @@ -840,6 +783,7 @@ flowchart TD pkg_tool_ask_user --> pkg_invariants pkg_tool_ask_user --> pkg_tools pkg_tool_ask_user --> pkg_user_interaction +<<<<<<< HEAD pkg_client_ui_command --> pkg_client_connection pkg_client_ui_command --> pkg_client_locale pkg_client_ui_command --> pkg_client_runtime @@ -869,6 +813,12 @@ flowchart TD pkg_client_ui_skill --> pkg_client_ui_slash pkg_client_ui_skill --> pkg_client_ui_slots pkg_client_ui_skill --> pkg_invariants +======= + pkg_client_runtime --> pkg_client_remotes + pkg_client_runtime --> pkg_invariants + pkg_client_runtime --> pkg_type_meta + pkg_client_runtime --> pkg_typert_registry +>>>>>>> d6e02ebbcc (feat(goal): add TypeRT gateway example) pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compact pkg_session_reference --> pkg_invariants @@ -1000,42 +950,29 @@ flowchart TD pkg_web_app --> pkg_bash_env pkg_web_app --> pkg_invariants pkg_web_app --> pkg_system_prompt - pkg_client_ui_model --> pkg_client_connection - pkg_client_ui_model --> pkg_client_locale - pkg_client_ui_model --> pkg_client_runtime - pkg_client_ui_model --> pkg_client_ui_command - pkg_client_ui_model --> pkg_client_ui_conversation - pkg_client_ui_model --> pkg_client_ui_primitives - pkg_client_ui_model --> pkg_client_ui_slash - pkg_client_ui_model --> pkg_client_ui_slots - pkg_client_ui_model --> pkg_invariants - pkg_client_ui_permission --> pkg_client_connection - pkg_client_ui_permission --> pkg_client_locale - pkg_client_ui_permission --> pkg_client_runtime - pkg_client_ui_permission --> pkg_client_schema_form - pkg_client_ui_permission --> pkg_client_ui_command - pkg_client_ui_permission --> pkg_client_ui_primitives - pkg_client_ui_permission --> pkg_client_ui_slash - pkg_client_ui_permission --> pkg_client_ui_slots - pkg_client_ui_permission --> pkg_invariants - pkg_client_ui_permission --> pkg_permission - pkg_client_ui_plan --> pkg_client_connection - pkg_client_ui_plan --> pkg_client_locale - pkg_client_ui_plan --> pkg_client_runtime - pkg_client_ui_plan --> pkg_client_ui_conversation - pkg_client_ui_plan --> pkg_client_ui_primitives - pkg_client_ui_plan --> pkg_client_ui_slots - pkg_client_ui_plan --> pkg_invariants - pkg_client_ui_plan --> pkg_plan_mode - pkg_client_ui_subagent --> pkg_client_locale - pkg_client_ui_subagent --> pkg_client_runtime - pkg_client_ui_subagent --> pkg_client_ui_conversation - pkg_client_ui_subagent --> pkg_client_ui_primitives - pkg_client_ui_subagent --> pkg_client_ui_slash - pkg_client_ui_subagent --> pkg_client_ui_slots - pkg_client_ui_subagent --> pkg_invariants - pkg_client_ui_subagent --> pkg_subagent - pkg_client_ui_subagent --> pkg_token_meter + pkg_client_locale --> pkg_client_runtime + pkg_client_locale --> pkg_client_ui_primitives + pkg_client_locale --> pkg_client_ui_slots + pkg_client_locale --> pkg_invariants + pkg_client_test_runtime --> pkg_client_runtime + pkg_client_test_runtime --> pkg_client_ui_slots + pkg_client_test_runtime --> pkg_client_web_react + pkg_client_test_runtime --> pkg_host_apiproxy + pkg_client_test_runtime --> pkg_invariants + pkg_client_ui_models --> pkg_client_connection + pkg_client_ui_models --> pkg_client_runtime + pkg_client_ui_models --> pkg_client_schema_form + pkg_client_ui_models --> pkg_client_ui_primitives + pkg_client_ui_models --> pkg_client_ui_slots + pkg_client_ui_models --> pkg_client_web_react + pkg_client_ui_models --> pkg_invariants + pkg_client_ui_settings --> pkg_client_runtime + pkg_client_ui_settings --> pkg_client_ui_primitives + pkg_client_ui_settings --> pkg_client_ui_slots + pkg_client_ui_settings --> pkg_invariants + pkg_client_ui_trajectory --> pkg_client_runtime + pkg_client_ui_trajectory --> pkg_client_ui_primitives + pkg_client_ui_trajectory --> pkg_invariants pkg_sdk_protocol --> pkg_invariants pkg_sdk_protocol --> pkg_llm pkg_sdk_protocol --> pkg_session @@ -1078,6 +1015,36 @@ flowchart TD pkg_jsonrpc --> pkg_sdk_protocol pkg_jsonrpc --> pkg_session pkg_jsonrpc --> pkg_subagent + pkg_client_ui_question --> pkg_client_locale + pkg_client_ui_question --> pkg_invariants + pkg_client_ui_settings_general --> pkg_client_connection + pkg_client_ui_settings_general --> pkg_client_locale + pkg_client_ui_settings_general --> pkg_client_runtime + pkg_client_ui_settings_general --> pkg_client_ui_primitives + pkg_client_ui_settings_general --> pkg_client_ui_settings + pkg_client_ui_settings_general --> pkg_client_ui_slots + pkg_client_ui_settings_general --> pkg_client_web_react + pkg_client_ui_settings_general --> pkg_invariants + pkg_client_ui_sidebar --> pkg_client_locale + pkg_client_ui_sidebar --> pkg_client_runtime + pkg_client_ui_sidebar --> pkg_client_ui_primitives + pkg_client_ui_sidebar --> pkg_client_ui_slots + pkg_client_ui_sidebar --> pkg_invariants + pkg_client_ui_slash --> pkg_client_locale + pkg_client_ui_slash --> pkg_client_runtime + pkg_client_ui_slash --> pkg_client_ui_primitives + pkg_client_ui_slash --> pkg_client_ui_slots + pkg_client_ui_slash --> pkg_invariants + pkg_client_ui_theme --> pkg_client_locale + pkg_client_ui_theme --> pkg_client_runtime + pkg_client_ui_theme --> pkg_client_ui_primitives + pkg_client_ui_theme --> pkg_client_ui_slots + pkg_client_ui_theme --> pkg_invariants + pkg_client_ui_workspace --> pkg_client_locale + pkg_client_ui_workspace --> pkg_client_runtime + pkg_client_ui_workspace --> pkg_client_ui_primitives + pkg_client_ui_workspace --> pkg_client_ui_slots + pkg_client_ui_workspace --> pkg_invariants pkg_agent_spine_demo --> pkg_agent pkg_agent_spine_demo --> pkg_agent_loop pkg_agent_spine_demo --> pkg_bash_env @@ -1111,6 +1078,22 @@ flowchart TD pkg_subagent_dsh_sdk --> pkg_session pkg_subagent_dsh_sdk --> pkg_subagent pkg_subagent_dsh_sdk --> pkg_subprocess + pkg_client_ui_conversation --> pkg_client_locale + pkg_client_ui_conversation --> pkg_client_runtime + pkg_client_ui_conversation --> pkg_client_ui_primitives + pkg_client_ui_conversation --> pkg_client_ui_slash + pkg_client_ui_conversation --> pkg_client_ui_slots + pkg_client_ui_conversation --> pkg_invariants + pkg_client_ui_conversation --> pkg_token_meter + pkg_client_ui_layout --> pkg_client_runtime + pkg_client_ui_layout --> pkg_client_ui_slots + pkg_client_ui_layout --> pkg_client_ui_theme + pkg_client_ui_layout --> pkg_invariants + pkg_client_ui_skill --> pkg_client_connection + pkg_client_ui_skill --> pkg_client_runtime + pkg_client_ui_skill --> pkg_client_ui_slash + pkg_client_ui_skill --> pkg_client_ui_slots + pkg_client_ui_skill --> pkg_invariants pkg_acp_demo --> pkg_acp pkg_acp_demo --> pkg_agent_spine_demo pkg_acp_demo --> pkg_app_boot @@ -1131,6 +1114,72 @@ flowchart TD pkg_cli_demo --> pkg_session_persistence_jsonl pkg_cli_demo --> pkg_tools pkg_cli_demo --> pkg_workspace_context + pkg_host_directory_picker_browse --> pkg_client_locale + pkg_host_directory_picker_browse --> pkg_client_runtime + pkg_host_directory_picker_browse --> pkg_client_ui_primitives + pkg_host_directory_picker_browse --> pkg_client_ui_slots + pkg_host_directory_picker_browse --> pkg_client_ui_workspace + pkg_host_directory_picker_browse --> pkg_invariants + pkg_host_directory_picker_native --> pkg_client_runtime + pkg_host_directory_picker_native --> pkg_client_ui_slots + pkg_host_directory_picker_native --> pkg_client_ui_workspace + pkg_host_directory_picker_native --> pkg_invariants + pkg_client_ui_command --> pkg_client_connection + pkg_client_ui_command --> pkg_client_locale + pkg_client_ui_command --> pkg_client_runtime + pkg_client_ui_command --> pkg_client_ui_conversation + pkg_client_ui_command --> pkg_client_ui_primitives + pkg_client_ui_command --> pkg_client_ui_slash + pkg_client_ui_command --> pkg_client_ui_slots + pkg_client_ui_command --> pkg_invariants + pkg_client_ui_goal --> pkg_client_connection + pkg_client_ui_goal --> pkg_client_locale + pkg_client_ui_goal --> pkg_client_runtime + pkg_client_ui_goal --> pkg_client_ui_conversation + pkg_client_ui_goal --> pkg_client_ui_primitives + pkg_client_ui_goal --> pkg_client_ui_slots + pkg_client_ui_goal --> pkg_goal + pkg_client_ui_goal --> pkg_invariants + pkg_client_ui_plan --> pkg_client_connection + pkg_client_ui_plan --> pkg_client_locale + pkg_client_ui_plan --> pkg_client_runtime + pkg_client_ui_plan --> pkg_client_ui_conversation + pkg_client_ui_plan --> pkg_client_ui_primitives + pkg_client_ui_plan --> pkg_client_ui_slots + pkg_client_ui_plan --> pkg_invariants + pkg_client_ui_plan --> pkg_plan_mode + pkg_client_ui_subagent --> pkg_client_locale + pkg_client_ui_subagent --> pkg_client_runtime + pkg_client_ui_subagent --> pkg_client_ui_conversation + pkg_client_ui_subagent --> pkg_client_ui_primitives + pkg_client_ui_subagent --> pkg_client_ui_slash + pkg_client_ui_subagent --> pkg_client_ui_slots + pkg_client_ui_subagent --> pkg_invariants + pkg_client_ui_subagent --> pkg_subagent + pkg_client_ui_subagent --> pkg_token_meter + pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse + 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_client_ui_model --> pkg_client_connection + pkg_client_ui_model --> pkg_client_locale + pkg_client_ui_model --> pkg_client_runtime + pkg_client_ui_model --> pkg_client_ui_command + pkg_client_ui_model --> pkg_client_ui_conversation + pkg_client_ui_model --> pkg_client_ui_primitives + pkg_client_ui_model --> pkg_client_ui_slash + pkg_client_ui_model --> pkg_client_ui_slots + pkg_client_ui_model --> pkg_invariants + pkg_client_ui_permission --> pkg_client_connection + pkg_client_ui_permission --> pkg_client_locale + pkg_client_ui_permission --> pkg_client_runtime + pkg_client_ui_permission --> pkg_client_schema_form + pkg_client_ui_permission --> pkg_client_ui_command + pkg_client_ui_permission --> pkg_client_ui_primitives + pkg_client_ui_permission --> pkg_client_ui_slash + pkg_client_ui_permission --> pkg_client_ui_slots + pkg_client_ui_permission --> pkg_invariants + pkg_client_ui_permission --> pkg_permission ``` | Package | Group | Depends on | @@ -1149,7 +1198,6 @@ flowchart TD | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`invariants`](../packages/support/invariants) | | [`base`](../packages/bundle/base) | `bundle` | [`invariants`](../packages/support/invariants) | | [`client-modules`](../packages/client/modules) | `client` | [`invariants`](../packages/support/invariants) | -| [`client-runtime`](../packages/client/runtime) | `client` | [`invariants`](../packages/support/invariants) | | [`client-schema-form`](../packages/client/schema-form) | `client` | [`invariants`](../packages/support/invariants) | | [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | [`invariants`](../packages/support/invariants) | | [`client-ui-slots`](../packages/client/ui-slots) | `client` | [`invariants`](../packages/support/invariants) | @@ -1168,10 +1216,6 @@ flowchart TD | [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) | | [`client-connection`](../packages/client/connection) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | -| [`client-locale`](../packages/client/locale) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | -| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`frontend-static`](../packages/host/frontend-static) | `host` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | @@ -1187,13 +1231,6 @@ flowchart TD | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`type-meta`](../packages/typert/type-meta) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | -| [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | -| [`client-ui-question`](../packages/client/ui-question) | `client` | [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) | -| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | -| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`host-api-gateway`](../packages/host/api-gateway) | `host` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | @@ -1210,16 +1247,17 @@ flowchart TD | [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`app-boot`](../packages/ui/app-boot) | `ui` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) | +<<<<<<< HEAD | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | +======= +>>>>>>> d6e02ebbcc (feat(goal): add TypeRT gateway example) | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | -| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | -| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`session-projection`](../packages/session-projection/session-projection) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) | -| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) | +| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`type-meta`](../packages/typert/type-meta) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pwsh-local`](../packages/bash/pwsh-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | @@ -1237,7 +1275,6 @@ flowchart TD | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`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/support/invariants) | | [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants) | @@ -1257,8 +1294,12 @@ flowchart TD | [`session-title-llm`](../packages/session-title/session-title-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`timeout`](../packages/util/timeout) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/ui/user-approval) | +<<<<<<< HEAD | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | +======= +| [`client-remotes`](../packages/client/remotes) | `client` | [`goal`](../packages/goal/goal), [`host-api-gateway`](../packages/host/api-gateway), [`invariants`](../packages/support/invariants) | +>>>>>>> d6e02ebbcc (feat(goal): add TypeRT gateway example) | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -1284,10 +1325,14 @@ flowchart TD | [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`session-title-llm`](../packages/session-title/session-title-llm) | | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +<<<<<<< HEAD | [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +======= +| [`client-runtime`](../packages/client/runtime) | `client` | [`client-remotes`](../packages/client/remotes), [`invariants`](../packages/support/invariants), [`type-meta`](../packages/typert/type-meta), [`typert-registry`](../packages/typert/registry) | +>>>>>>> d6e02ebbcc (feat(goal): add TypeRT gateway example) | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | @@ -1309,10 +1354,11 @@ flowchart TD | [`repository-plugin`](../packages/cordis/repository-plugin) | `cordis` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`paths`](../packages/util/paths), [`skill-local`](../packages/skill/skill-local) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) | -| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/ui/permission) | -| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | -| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | +| [`client-locale`](../packages/client/locale) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | +| [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | +| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) | | [`sdk-protocol`](../packages/sdk/sdk-protocol) | `sdk` | [`invariants`](../packages/support/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/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | @@ -1320,8 +1366,26 @@ flowchart TD | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | +| [`client-ui-question`](../packages/client/ui-question) | `client` | [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) | +| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | +| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`bash-env`](../packages/bash/bash-env), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`sdk-client`](../packages/sdk/sdk-client) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/sdk-client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | +| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) | +| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | +| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | +| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | +| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | +| [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | +| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | +| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | +| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`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/support/invariants) | +| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/ui/permission) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 3c037198da..48732dbbb0 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -340,7 +340,7 @@ Source: [`packages/feedback/command-feedback/src/index.ts:24`](../packages/feedb 'goal/change': GoalChangeMeta ``` -Source: [`packages/goal/goal/src/domain.ts:81`](../packages/goal/goal/src/domain.ts) +Source: [`packages/goal/goal/src/domain.ts:66`](../packages/goal/goal/src/domain.ts) ### `hook/*` diff --git a/knip.json b/knip.json index 32c9e20dbf..3ce9a32d99 100644 --- a/knip.json +++ b/knip.json @@ -115,6 +115,15 @@ "tests/**/*.ts" ] }, + "packages/client/remotes": { + "entry": [ + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] + }, "packages/client/ui-primitives": { "entry": [ "tests/**/*.spec.tsx" diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 681f0d5121..001c43948d 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -124,6 +124,9 @@ - id: connection name: '@deepseek-ai/dsh-client-connection' + - id: client-remotes + name: '@deepseek-ai/dsh-client-remotes' + - id: client-runtime name: '@deepseek-ai/dsh-client-runtime' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 29eeb24009..89b5e8e2a7 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -36,6 +36,7 @@ "@deepseek-ai/dsh-client-hmr": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", + "@deepseek-ai/dsh-client-remotes": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-command": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", diff --git a/packages/client/remotes/README.i18n.yaml b/packages/client/remotes/README.i18n.yaml new file mode 100644 index 0000000000..86f2aded18 --- /dev/null +++ b/packages/client/remotes/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/client/remotes/README.md +README.md: e29188b8e3ae5ecefe194f1355558e9bdeaae7dd +README.zh.md: e6425ab190a28e0a38c3713c4e21645789a8f00c diff --git a/packages/client/remotes/README.md b/packages/client/remotes/README.md new file mode 100644 index 0000000000..e29188b8e3 --- /dev/null +++ b/packages/client/remotes/README.md @@ -0,0 +1,22 @@ +# @deepseek-ai/dsh-client-remotes + +English | [中文](README.zh.md) + +Platform-neutral Client facade for Host Remote capabilities selected by this application. Its Client entry imports generated `/remote` artifacts as runtime values, mounts each contribution through `ctx.api`, and re-exports their declaration merges. Client business packages depend on this facade rather than the Host API Gateway or individual Remote runtime entries. + +The current assembly mounts only the Goal Remote contribution. Cordis effect ownership withdraws every contribution when this assembly unloads, while the Client face of `@deepseek-ai/dsh-host-api-gateway` owns descriptor validation, concrete root and scoped methods, invocation, and cancellation. + +This package contains no transport or Host discovery logic. It can be reused by Web or a future TUI Client that provides the same React-free `ctx.api` contract. + +## Model Experience + +None, as this Client assembly selects Remote application methods and registers no model surface. + +#### KV Cache effect + +No direct effect; mounted Host capabilities own any model-visible behavior they trigger. + +## Known Limitations and Deferred Work + +- The capability set is fixed by explicit build-time value imports; the Client does not discover the Host's active Services or Remote definitions at runtime. +- Additional capabilities require an explicit `/remote` value import and mount in this assembly. diff --git a/packages/client/remotes/README.zh.md b/packages/client/remotes/README.zh.md new file mode 100644 index 0000000000..e6425ab190 --- /dev/null +++ b/packages/client/remotes/README.zh.md @@ -0,0 +1,22 @@ +# @deepseek-ai/dsh-client-remotes + +[English](README.md) | 中文 + +为本应用选定的 Host Remote 能力提供平台无关的 Client 外观。其 Client 入口以运行时值形式导入生成的 `/remote` 产物,通过 `ctx.api` 挂载每项贡献,并重新导出对应的声明合并。Client 业务包依赖此外观,而不依赖 Host API Gateway 或单独的 Remote 运行时入口。 + +当前组合仅挂载 Goal Remote 贡献。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-host-api-gateway` 的 Client 侧负责描述符校验、具体的根级方法和作用域方法、调用与取消。 + +本包不包含传输逻辑或 Host 发现逻辑。Web 和未来的 TUI Client 只要提供同一份不依赖 React 的 `ctx.api` 契约,均可复用本包。 + +## 模型体验 + +无,因为此 Client 组合只选择应用的 Remote 方法,不注册任何模型接口。 + +#### KV Cache 影响 + +无直接影响;其触发的任何模型可见行为均由已挂载的 Host 能力负责。 + +## 已知限制与暂缓事项 + +- 能力集合由构建时显式导入的值固定确定;Client 不会在运行时发现 Host 中已启用的服务或 Remote 定义。 +- 若要增加能力,必须显式导入相应的 `/remote` 值并在此组合中挂载。 diff --git a/packages/client/remotes/package.json b/packages/client/remotes/package.json new file mode 100644 index 0000000000..ba4e7b6a01 --- /dev/null +++ b/packages/client/remotes/package.json @@ -0,0 +1,55 @@ +{ + "name": "@deepseek-ai/dsh-client-remotes", + "description": "Platform-neutral assembly of explicitly selected Host Remote contributions", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dshClient": { + "inject": [ + "@deepseek-ai/dsh-host-api-gateway" + ], + "platform": "web", + "immediately": true + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "BSD-3-Clause", + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ], + "peerDependencies": { + "@deepseek-ai/dsh-host-api-gateway": "^0.0.1", + "@deepseek-ai/dsh-goal": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-host-api-gateway": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/client/remotes/src/client/index.ts b/packages/client/remotes/src/client/index.ts new file mode 100644 index 0000000000..09757b5e9e --- /dev/null +++ b/packages/client/remotes/src/client/index.ts @@ -0,0 +1,19 @@ +/** Platform-neutral assembly of generated Host Remote contributions. */ + +import type { Context } from 'cordis' +import type {} from '@deepseek-ai/dsh-host-api-gateway/client' +import goalsRemote from '@deepseek-ai/dsh-goal/remote' + +export type { ClientApi } from '@deepseek-ai/dsh-host-api-gateway/client' +export type {} from '@deepseek-ai/dsh-goal/remote' + +/** Required service: the typed Client API contribution mount. */ +export const inject = ['api'] + +/** + * Mount the Host capabilities explicitly selected for this Client assembly. + * @param ctx - Client Cordis root carrying the typed API service. + */ +export function apply(ctx: Context): void { + ctx.api.mount(goalsRemote) +} diff --git a/packages/client/remotes/src/index.ts b/packages/client/remotes/src/index.ts new file mode 100644 index 0000000000..c8c4ff20be --- /dev/null +++ b/packages/client/remotes/src/index.ts @@ -0,0 +1,4 @@ +/** Host Loader entry for the Client Remote contribution assembly. */ + +/** Host plugin body; the selected contributions mount only in Client environments. */ +export function apply(): void {} diff --git a/packages/client/remotes/src/invariant.ts b/packages/client/remotes/src/invariant.ts new file mode 100644 index 0000000000..1a6b0ba237 --- /dev/null +++ b/packages/client/remotes/src/invariant.ts @@ -0,0 +1,24 @@ +/** Package-owned invariant companion for `@deepseek-ai/dsh-client-remotes`. */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-remotes' + +/** Cordis companion plugin name. */ +export const name = 'client-remotes-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** No runtime invariant: the API service owns contribution and method lifecycle atomically. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/remotes/tests/built-lib.e2e.ts b/packages/client/remotes/tests/built-lib.e2e.ts new file mode 100644 index 0000000000..bbba218844 --- /dev/null +++ b/packages/client/remotes/tests/built-lib.e2e.ts @@ -0,0 +1,214 @@ +import { execFile } from 'node:child_process' +import { existsSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { describe, expect, it } from 'vitest' + +/** + * Built-artifact smoke for the first generated Remote: plain Node boots the + * Host and Browser bundle handoffs, then crosses the real `/api2` HTTP route. + */ + +const packageDir = fileURLToPath(new URL('..', import.meta.url)) +const root = resolve(packageDir, '../../..') +const artifact = (path: string): string => join(root, path) +const artifactUrl = (path: string): string => pathToFileURL(artifact(path)).href + +const requiredArtifacts = [ + 'packages/client/connection/lib/client.js', + 'packages/client/connection/lib/index.js', + 'packages/client/remotes/lib/client.js', + 'packages/core/agent/lib/index.js', + 'packages/core/session/lib/index.js', + 'packages/goal/goal/lib/index.js', + 'packages/goal/goal/lib/typert.host.js', + 'packages/host/api-gateway/lib/client.js', + 'packages/host/api-gateway/lib/index.js', + 'packages/typert/registry/lib/client.js', + 'packages/typert/registry/lib/index.js', +].every(path => existsSync(artifact(path))) + +describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { + it('runs root and Agent-scoped calls through generated bundles and real HTTP', async () => { + const urls = Object.fromEntries(Object.entries({ + agent: 'packages/core/agent/lib/index.js', + apiGatewayClient: 'packages/host/api-gateway/lib/client.js', + apiGatewayHost: 'packages/host/api-gateway/lib/index.js', + connectionClient: 'packages/client/connection/lib/client.js', + connectionHost: 'packages/client/connection/lib/index.js', + goal: 'packages/goal/goal/lib/index.js', + goalTypert: 'packages/goal/goal/lib/typert.host.js', + registryClient: 'packages/typert/registry/lib/client.js', + registryHost: 'packages/typert/registry/lib/index.js', + remotesClient: 'packages/client/remotes/lib/client.js', + session: 'packages/core/session/lib/index.js', + }).map(([key, path]) => [key, artifactUrl(path)])) + const script = ` + import { createServer } from 'node:http' + import * as cordis from 'cordis' + + const urls = ${JSON.stringify(urls)} + const { Context } = cordis + const { default: AgentRegistry } = await import(urls.agent) + const connectionHost = await import(urls.connectionHost) + const { default: TypertGatewayService } = await import(urls.apiGatewayHost) + const { default: GoalService } = await import(urls.goal) + const { TYPERT } = await import(urls.goalTypert) + const { default: TypertRegistry } = await import(urls.registryHost) + const { Session, SessionId } = await import(urls.session) + + const routes = [] + const host = new Context() + host.provide('httpServer', { + register(route) { + routes.push(route) + return () => { routes.splice(routes.indexOf(route), 1) } + }, + tapIndex() { return () => {} }, + port: 0, + }) + await host.plugin({ inject: connectionHost.inject, apply: connectionHost.apply }) + await host.plugin(TypertRegistry) + await host.plugin(AgentRegistry) + await host.plugin(TypertGatewayService) + await host.plugin(GoalService) + host.typert.register(TYPERT) + + const makeAgent = rawId => { + const session = new Session(SessionId(rawId)) + return { + id: session.id, + options: {}, + session, + ctx: host.extend(), + status: 'idle', + acceptsNextStep: false, + send() {}, + updateInbox() { return 'not-found' }, + followup() {}, + steer() { return { outcome: Promise.resolve({ status: 'rejected' }) } }, + inject(input) { session.append('user/message', input, { surfaceOp: 'append' }) }, + reserveTurnAdmission() {}, + cancel() {}, + whenIdle() { return Promise.resolve() }, + } + } + const rootAgent = makeAgent('built-root-agent') + const scopedAgent = makeAgent('built-scoped-agent') + host.agents.register(rootAgent) + host.agents.register(scopedAgent) + + if (routes.length !== 1) throw new Error('Gateway did not register exactly one /api2 route') + const server = createServer((request, response) => { void routes[0].handler(request, response) }) + await new Promise(resolveListen => server.listen(0, '127.0.0.1', resolveListen)) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('HTTP server has no TCP address') + const origin = 'http://127.0.0.1:' + String(address.port) + + const handoffs = new Map() + globalThis.window = { + __ModuleLoader__: { + load(handoff) { handoffs.set(handoff.id, handoff) }, + }, + } + globalThis.location = { hostname: '127.0.0.1', origin, search: '' } + await import(urls.registryClient) + await import(urls.connectionClient) + await import(urls.apiGatewayClient) + await import(urls.remotesClient) + + const instantiate = id => { + const handoff = handoffs.get(id) + if (handoff === undefined) throw new Error('missing Client bundle handoff ' + id) + return handoff.factory(specifier => { + if (specifier === 'cordis') return cordis + throw new Error('unexpected Client external ' + specifier) + }) + } + const client = new Context() + for (const id of [ + '@deepseek-ai/dsh-typert-registry', + '@deepseek-ai/dsh-client-connection', + '@deepseek-ai/dsh-host-api-gateway', + '@deepseek-ai/dsh-client-remotes', + ]) { + const plugin = instantiate(id) + await client.plugin({ inject: plugin.inject, apply: plugin.apply }) + } + client.typert.contexts.registerClient('agent', { + identity: candidate => candidate.builtAgentId, + }) + + let invalidRejected = false + try { + await client.api.goals.create(rootAgent.id, { objective: 1 }) + } catch { + invalidRejected = true + } + const rootResult = await client.api.goals.create(rootAgent.id, { objective: 'root goal' }) + const agentContext = client.extend({ builtAgentId: scopedAgent.id }) + const scopedResult = await agentContext.goals.create({ objective: 'scoped goal', maxGoalRounds: 3 }) + const result = { + invalidRejected, + rootResult, + scopedResult, + rootGoal: host.goals.get(rootAgent)?.objective, + scopedGoal: host.goals.get(scopedAgent)?.objective, + rootEvents: rootAgent.session.events.length, + scopedEvents: scopedAgent.session.events.length, + } + + await client.fiber.dispose() + await new Promise((resolveClose, rejectClose) => server.close(error => { + if (error === undefined) resolveClose() + else rejectClose(error) + })) + await host.fiber.dispose() + console.log(JSON.stringify(result)) + ` + + const result = await runPlainNode(script) + expect(result.exitCode, `stderr:\n${result.stderr}`).toBe(0) + const output = JSON.parse(result.stdout.trim().split('\n').at(-1) ?? '{}') as { + invalidRejected: boolean + rootResult: { ref: { id: string; revision: number } } + scopedResult: { ref: { id: string; revision: number } } + rootGoal: string + scopedGoal: string + rootEvents: number + scopedEvents: number + } + expect(output).toMatchObject({ + invalidRejected: true, + rootResult: { ref: { revision: 1 } }, + scopedResult: { ref: { revision: 1 } }, + rootGoal: 'root goal', + scopedGoal: 'scoped goal', + rootEvents: 1, + scopedEvents: 1, + }) + expect(output.rootResult.ref.id).toMatch(/^goal-/) + expect(output.scopedResult.ref.id).toMatch(/^goal-/) + }, 60_000) +}) + +/** Execute one ESM script without tsx or a TypeScript loader. */ +function runPlainNode(script: string): Promise<{ + readonly exitCode: number | null + readonly stdout: string + readonly stderr: string +}> { + return new Promise((resolveRun) => { + execFile(process.execPath, ['--input-type=module', '-e', script], { + cwd: packageDir, + encoding: 'utf8', + timeout: 55_000, + }, (error, stdout, stderr) => { + resolveRun({ + exitCode: error === null ? 0 : typeof error.code === 'number' ? error.code : null, + stdout, + stderr, + }) + }) + }) +} diff --git a/packages/client/remotes/tsconfig.json b/packages/client/remotes/tsconfig.json new file mode 100644 index 0000000000..c99a5fce19 --- /dev/null +++ b/packages/client/remotes/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../host/api-gateway" + }, + { + "path": "../../ui/commands" + }, + { + "path": "../../goal/goal" + }, + { + "path": "../../session-title/session-title" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/client/remotes/tsdown.config.ts b/packages/client/remotes/tsdown.config.ts new file mode 100644 index 0000000000..20fa098462 --- /dev/null +++ b/packages/client/remotes/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-remotes', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index b636316b68..cc51aa772d 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -24,7 +24,9 @@ }, "dshClient": { "inject": [ - "@deepseek-ai/dsh-client-connection" + "@deepseek-ai/dsh-client-connection", + "@deepseek-ai/dsh-client-remotes", + "@deepseek-ai/dsh-typert-registry" ], "platform": "web", "immediately": true @@ -47,11 +49,17 @@ }, "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-client-remotes": "^0.0.1", + "@deepseek-ai/dsh-type-meta": "^0.0.1", + "@deepseek-ai/dsh-typert-registry": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-client-remotes": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", + "@deepseek-ai/dsh-typert-registry": "workspace:^", "@types/react": "~18.3.1", "cordis": "^4.0.0-rc.7" }, diff --git a/packages/client/runtime/src/client/agents/scope.ts b/packages/client/runtime/src/client/agents/scope.ts index af6fa3afcd..ba4fd8ede7 100644 --- a/packages/client/runtime/src/client/agents/scope.ts +++ b/packages/client/runtime/src/client/agents/scope.ts @@ -18,6 +18,7 @@ import { Context as CordisContext } from 'cordis' import type { Context, Fiber } from 'cordis' import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { TypeRTRemoteContextApi } from '@deepseek-ai/dsh-type-meta' /** Context tag written by {@link createScope}. */ const kScope = Symbol('dsh.client.scope') @@ -29,7 +30,7 @@ export interface AgentScopeHandle { * through it (passing it as the dispatch subject routes to this agent's * tagged listeners plus every untagged one). */ - ctx: Context + ctx: Context & TypeRTRemoteContextApi<'agent'> /** Backing fiber (dispose tears down every scope-owned registration). */ fiber: Fiber } @@ -48,15 +49,16 @@ function agentScope(): void {} */ export function createScope(ctx: Context, key: SessionId): AgentScopeHandle { const fiber = ctx.plugin(agentScope) + const scoped = fiber.ctx.extend({ + [kScope]: key, + [CordisContext.filter](listenerCtx: Context): boolean { + const tag = scopeOf(listenerCtx) + return tag === undefined || tag === key + }, + }) as Context & TypeRTRemoteContextApi<'agent'> return { fiber, - ctx: fiber.ctx.extend({ - [kScope]: key, - [CordisContext.filter](listenerCtx: Context): boolean { - const tag = scopeOf(listenerCtx) - return tag === undefined || tag === key - }, - }), + ctx: scoped, } } diff --git a/packages/client/runtime/src/client/contract/sessions.ts b/packages/client/runtime/src/client/contract/sessions.ts index fbb0bb1a4e..8e9c530720 100644 --- a/packages/client/runtime/src/client/contract/sessions.ts +++ b/packages/client/runtime/src/client/contract/sessions.ts @@ -11,6 +11,7 @@ import type { Context } from 'cordis' import type { RpcResult, SessionId, SubagentAddress, } from '@deepseek-ai/dsh-client-connection/client' +import type { TypeRTRemoteContextApi } from '@deepseek-ai/dsh-type-meta' import type { HostObservable, SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionSearchResultItem } from '../sessions/manager.ts' import type { @@ -19,6 +20,9 @@ import type { import type { SessionFace } from './session.ts' import type { ObservableSnapshot } from './store.ts' +/** Client Cordis Context carrying one Agent identity and its generated Remote namespaces. */ +export type AgentContext = Context & TypeRTRemoteContextApi<'agent'> + /** The sessions-service face injected as `ctx.sessions`. */ export interface ISessions { /** The useSessions standard feed (list rows + current selection; read face — writes stay inside the domain). */ @@ -95,7 +99,7 @@ export interface ISessions { * @param id - session id. * @returns scoped ctx, or undefined for a session neither listed nor already scoped. */ - scope(id: SessionId): Context | undefined + scope(id: SessionId): AgentContext | undefined /** * Read the Agent scope tag off a context (service-method seam: fetch * bundles must reach scope resolution through ctx.sessions). diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 06f88a9131..f1efd6a65d 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -1,6 +1,8 @@ /** Browser runtime services for slots, sessions, workspaces, and connection-stream delivery. */ import type { Context } from 'cordis' import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type {} from '@deepseek-ai/dsh-client-remotes/client' +import type { TypeRTContext } from '@deepseek-ai/dsh-type-meta' import type { MaybeSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import { SlotsService } from './slots.ts' import { SessionsService } from './sessions/service.ts' @@ -26,7 +28,7 @@ export type { ISession, ProjectionsFace, SessionFace } from './contract/session. export type { ISessionHistory, SessionHistoryFace, SessionHistorySnapshot, } from './contract/session-history.ts' -export type { ISessions } from './contract/sessions.ts' +export type { AgentContext, ISessions } from './contract/sessions.ts' export type { IWorkspaces } from './contract/workspaces.ts' export type { SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary, @@ -75,6 +77,13 @@ export type { SessionId } from '@deepseek-ai/dsh-client-connection/client' /** Client-side Cordis context after declaration merging. */ export type ClientContext = Context +declare module '@deepseek-ai/dsh-type-meta' { + interface TypeRTContextMap { + /** Client Agent scope identity; the agent and session share one wire id. */ + agent: TypeRTContext + } +} + /** The conversation-snapshot selector hook (ConvViewProps/ToolRowProps take this). */ export type UseConversationSession = SnapshotSelectorHook @@ -170,8 +179,8 @@ declare module 'cordis' { } } -/** Required services: the wire handle mounted by the connection plugin. */ -export const inject = ['connection'] +/** Required services: the typed Remote API, wire handle, and Client TypeRT registry. */ +export const inject = ['api', 'connection', 'typert'] /** Mounts the browser runtime services and connection stream. * @param ctx - Client Cordis context. @@ -180,6 +189,9 @@ export function apply(ctx: Context): void { ctx.plugin(SlotsService) const connection = ctx.get('connection') as ConnectionHandle const sessions = new SessionsService(ctx, connection.api) + ctx.typert.contexts.registerClient('agent', { + identity: candidate => sessions.scopeOf(candidate), + }) const sessionHistory = new SessionHistoryService(ctx, connection.api) const workspaces = new WorkspacesService(ctx, connection.api, sessions) ctx.effect( diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index b1b271e702..621760df02 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -29,7 +29,7 @@ import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/t import type { SnapshotStore } from '../contract/store.ts' import { createSnapshotStore } from '../contract/store.ts' import type { SessionFace } from '../contract/session.ts' -import type { ISessions } from '../contract/sessions.ts' +import type { AgentContext, ISessions } from '../contract/sessions.ts' import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts' import { SessionManager } from './manager.ts' import type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './manager.ts' @@ -127,7 +127,7 @@ export interface SessionBinding { readonly sessionId: SessionId /** The outward session face only — feature code never sees the concrete class. */ readonly session: SessionFace - readonly ctx: Context + readonly ctx: AgentContext } // Scope primitives live in ../agents/scope.ts (the client mirror of host @@ -182,7 +182,7 @@ function increasedForkTitle(title: string): string { interface ScopeRecord { fiber: Fiber - ctx: Context + ctx: AgentContext binding: SessionBinding /** The concrete Session for runtime-internal entry points (staging open()); the binding carries only the outward face. */ session: Session @@ -483,7 +483,7 @@ export class SessionsService implements ISessions { * @param id - session id (the agent identity — 1:1 same axis). * @returns scoped ctx, or undefined for a session neither listed nor already scoped. */ - scope(id: SessionId): Context | undefined { + scope(id: SessionId): AgentContext | undefined { return this.resolve(id)?.ctx } diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index 14e51fae8e..5635793122 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -8,6 +8,7 @@ import { describe, expect, it } from 'vitest' import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client' import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import * as RuntimeClient from '../src/client/index.ts' import type { SessionsService } from '../src/client/sessions/service.ts' import type { WorkspacesService } from '../src/client/workspaces/service.ts' @@ -22,6 +23,7 @@ interface Bench { async function mount(): Promise { const ctx = new Context() + await ctx.plugin(TypertRegistry) const api = new FakeApiClient() const bench: Bench = { ctx, api, sinks: undefined, stopped: 0 } const handle: ConnectionHandle = { @@ -36,6 +38,7 @@ async function mount(): Promise { }, } ctx.reflect.provide('connection', handle) + ctx.reflect.provide('api', {}) await ctx.plugin(RuntimeClient).await() return bench } diff --git a/packages/client/runtime/tsconfig.json b/packages/client/runtime/tsconfig.json index f1512c7059..85ba61d41a 100644 --- a/packages/client/runtime/tsconfig.json +++ b/packages/client/runtime/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../connection" }, + { + "path": "../remotes" + }, { "path": "../../host/apiproxy" }, @@ -43,6 +46,12 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../typert/type-meta" + }, + { + "path": "../../typert/registry" } ], "exclude": [ diff --git a/packages/client/test-runtime/src/sessions.ts b/packages/client/test-runtime/src/sessions.ts index e313b63fd2..4747929572 100644 --- a/packages/client/test-runtime/src/sessions.ts +++ b/packages/client/test-runtime/src/sessions.ts @@ -3,7 +3,7 @@ import type { Context } from 'cordis' import { createScope, scopeOf, SessionProvideChannel } from '@deepseek-ai/dsh-client-runtime/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { - ConversationSnapshot, ISessions, ObservableSnapshot, ProjectionsFace, SessionFace, SessionId, + AgentContext, ConversationSnapshot, ISessions, ObservableSnapshot, ProjectionsFace, SessionFace, SessionId, SessionListState, SessionProvideDescriptor, SessionSearchResultItem, SessionSummary, SnapshotStore, SubagentAddress, } from '@deepseek-ai/dsh-client-runtime/client' @@ -134,7 +134,7 @@ interface SessionRecord { summary: SessionSummary snapshot: SnapshotStore session: FixtureSession - scope: Context | undefined + scope: AgentContext | undefined scopeFiber: { dispose(): Promise } | undefined /** Materialized standard-props bundle (identity-stable per session; invalidated on roster change). */ provideInfo: SessionProvideInfo | undefined @@ -144,7 +144,7 @@ interface SessionRecord { export interface TestSessionBinding { readonly sessionId: SessionId readonly session: FixtureSession - readonly ctx: Context + readonly ctx: AgentContext } /** @@ -345,7 +345,7 @@ export class TestSessions implements ISessions { * @param id - session id. * @returns the scoped context, or undefined for unknown sessions. */ - scope(id: string): Context | undefined { + scope(id: string): AgentContext | undefined { const record = this.records.get(id as SessionId) if (record === undefined) return undefined if (record.scope === undefined) { diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index b8da6049e8..48f4aadeed 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -382,6 +382,30 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'clear(agent: Agent, ref: GoalRef): GoalRef', jsDoc: '/**\n * Clear the current goal while retaining a durable tombstone and history.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the tombstone ref whose revision is one past the cleared snapshot.\n */', }, + { + signature: '@Remote(\'create\') remoteExportCreate(agent: Agent, request: CreateGoalRequest): CreateGoalResult', + jsDoc: '/**\n * Create one Goal through the remote boundary.\n * @param agent - exact live Agent resolved from the wire identity.\n * @param request - objective and optional round cap.\n * @returns the created Goal identity.\n */', + }, + { + signature: '@Remote(\'edit\') remoteExportEdit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView', + jsDoc: '/**\n * Edit one Goal through the remote boundary.\n * @param agent - exact live Agent resolved from the wire identity.\n * @param ref - expected current revision.\n * @param request - replacement fields.\n * @returns the edited Goal view.\n */', + }, + { + signature: '@Remote(\'pause\') remoteExportPause(agent: Agent, ref: GoalRef): GoalView', + jsDoc: '/**\n * Pause one Goal through the remote boundary.\n * @param agent - exact live Agent resolved from the wire identity.\n * @param ref - expected current revision.\n * @returns the paused Goal view.\n */', + }, + { + signature: '@Remote(\'resume\') remoteExportResume(agent: Agent, ref: GoalRef): GoalView', + jsDoc: '/**\n * Resume one Goal through the remote boundary.\n * @param agent - exact live Agent resolved from the wire identity.\n * @param ref - expected current revision.\n * @returns the resumed Goal view.\n */', + }, + { + signature: '@Remote(\'complete\') remoteExportComplete(agent: Agent, ref: GoalRef): GoalView', + jsDoc: '/**\n * Complete one Goal through the remote boundary.\n * @param agent - exact live Agent resolved from the wire identity.\n * @param ref - expected current revision.\n * @returns the completed Goal view.\n */', + }, + { + signature: '@Remote(\'clear\') remoteExportClear(agent: Agent, ref: GoalRef): GoalRef', + jsDoc: '/**\n * Clear one terminal Goal through the remote boundary.\n * @param agent - exact live Agent resolved from the wire identity.\n * @param ref - expected current revision.\n * @returns the committed clear revision.\n */', + }, ], }, { @@ -1859,6 +1883,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'CreateGoalRequest', declaration: 'export interface CreateGoalRequest {\n readonly objective: string;\n readonly maxGoalRounds?: number;\n}', }, + { + name: 'CreateGoalResult', + declaration: 'export interface CreateGoalResult {\n readonly ref: GoalRef;\n}', + }, { name: 'CreateSessionOptions', declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n };\n}', diff --git a/packages/goal/goal/package.json b/packages/goal/goal/package.json index 397e5717ba..fccf7de3be 100644 --- a/packages/goal/goal/package.json +++ b/packages/goal/goal/package.json @@ -23,6 +23,14 @@ "types": "./lib/types/client.d.ts", "default": "./lib/types/client.js" }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, @@ -30,7 +38,13 @@ "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", + "lib/typert.remote-client.d.ts.map", + "src" ], "license": "BSD-3-Clause", "peerDependencies": { @@ -41,6 +55,7 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-type-meta": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { @@ -56,6 +71,7 @@ "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index 87ea52018e..0997aad0dc 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -12,6 +12,7 @@ import type { ZodType } from 'zod' import { agentEvents } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import { Remote, bindTypeRTGateway } from '@deepseek-ai/dsh-type-meta' // Type-only: resolves ctx.sessionProjections for the optional unit child. import type {} from '@deepseek-ai/dsh-session-projection' import { @@ -189,6 +190,9 @@ export class GoalService extends Service { private readonly resolved: ResolvedConfig private readonly caches = new WeakMap() + /** Explicit participation in the TypeRT Gateway under the Cordis service key. */ + readonly typertGateway = bindTypeRTGateway(this, 'goals') + constructor(ctx: Context, config: Config = {}) { super(ctx, 'goals') this.resolved = { @@ -576,6 +580,7 @@ export class GoalService extends Service { * @param request - objective and optional round cap. * @returns the created Goal identity. */ + @Remote('create') remoteExportCreate(agent: Agent, request: CreateGoalRequest): CreateGoalResult { const view = this.create(agent, request) return { ref: { id: view.id, revision: view.revision } } @@ -588,6 +593,7 @@ export class GoalService extends Service { * @param request - replacement fields. * @returns the edited Goal view. */ + @Remote('edit') remoteExportEdit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView { return this.edit(agent, ref, request) } @@ -598,6 +604,7 @@ export class GoalService extends Service { * @param ref - expected current revision. * @returns the paused Goal view. */ + @Remote('pause') remoteExportPause(agent: Agent, ref: GoalRef): GoalView { return this.pause(agent, ref) } @@ -608,6 +615,7 @@ export class GoalService extends Service { * @param ref - expected current revision. * @returns the resumed Goal view. */ + @Remote('resume') remoteExportResume(agent: Agent, ref: GoalRef): GoalView { return this.resume(agent, ref) } @@ -618,6 +626,7 @@ export class GoalService extends Service { * @param ref - expected current revision. * @returns the completed Goal view. */ + @Remote('complete') remoteExportComplete(agent: Agent, ref: GoalRef): GoalView { return this.complete(agent, ref) } @@ -628,6 +637,7 @@ export class GoalService extends Service { * @param ref - expected current revision. * @returns the committed clear revision. */ + @Remote('clear') remoteExportClear(agent: Agent, ref: GoalRef): GoalRef { return this.clear(agent, ref) } diff --git a/packages/goal/goal/tsconfig.json b/packages/goal/goal/tsconfig.json index 9663f894fe..f106707bd3 100644 --- a/packages/goal/goal/tsconfig.json +++ b/packages/goal/goal/tsconfig.json @@ -35,6 +35,9 @@ { "path": "../../session-projection/session-projection" }, + { + "path": "../../typert/type-meta" + }, { "path": "../../support/invariants" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index caf1f8a5ba..d9a4453a61 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1131,6 +1131,9 @@ importers: '@deepseek-ai/dsh-client-modules': specifier: workspace:^ version: link:../../client/modules + '@deepseek-ai/dsh-client-remotes': + specifier: workspace:^ + version: link:../../client/remotes '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../../client/runtime @@ -1345,6 +1348,21 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/client/remotes: + devDependencies: + '@deepseek-ai/dsh-goal': + specifier: workspace:^ + version: link:../../goal/goal + '@deepseek-ai/dsh-host-api-gateway': + specifier: workspace:^ + version: link:../../host/api-gateway + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/client/runtime: dependencies: '@deepseek-ai/dsh-client-connection': @@ -1387,12 +1405,21 @@ importers: specifier: ~4.4.7 version: 4.4.7(@types/react@18.3.31)(immer@10.2.0)(react@18.3.1) devDependencies: + '@deepseek-ai/dsh-client-remotes': + specifier: workspace:^ + version: link:../remotes '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../../typert/type-meta + '@deepseek-ai/dsh-typert-registry': + specifier: workspace:^ + version: link:../../typert/registry '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -3509,6 +3536,9 @@ importers: '@deepseek-ai/dsh-session-projection': specifier: workspace:^ version: link:../../session-projection/session-projection + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../../typert/type-meta cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 84013225f7..088329e83d 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -90,6 +90,7 @@ export const LINK_MAP: Readonly> = { FsWriteIntent: 'filesystem.md', FsWriteOutcome: 'filesystem.md', CreateGoalRequest: 'goal.md', + CreateGoalResult: 'goal.md', EditGoalRequest: 'goal.md', GoalBlockReason: 'goal.md', GoalChanged: 'goal.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 603ad20d8e..84b957e633 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -199,7 +199,7 @@ { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalView", - "source": "packages/goal/goal/src/domain.ts" + "source": "packages/goal/goal/src/types.ts" }, { "doc": "docs/core-data-structures/goal.md", @@ -219,12 +219,12 @@ { "doc": "docs/core-data-structures/goal.md", "symbol": "CreateGoalRequest", - "source": "packages/goal/goal/src/domain.ts" + "source": "packages/goal/goal/src/types.ts" }, { "doc": "docs/core-data-structures/goal.md", "symbol": "EditGoalRequest", - "source": "packages/goal/goal/src/domain.ts" + "source": "packages/goal/goal/src/types.ts" }, { "doc": "docs/core-data-structures/goal.md", diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 7e81b30e07..78745dbed1 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -57,6 +57,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/web-react': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/schema-form': { kind: 'none', reason: 'Browser-side form-rendering library; registers no model surface.' }, 'packages/client/connection': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, + 'packages/client/remotes': { kind: 'none', reason: 'Client-side Remote assembly; selected business methods own any model-visible effect.' }, 'packages/client/runtime': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-layout': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-sidebar': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, diff --git a/tsconfig.client.json b/tsconfig.client.json index b0567f762e..327b337963 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -53,6 +53,7 @@ { "path": "./packages/client/connection" }, { "path": "./packages/typert/registry" }, { "path": "./packages/host/api-gateway" }, + { "path": "./packages/client/remotes" }, { "path": "./packages/client/runtime" }, { "path": "./packages/client/test-runtime" }, { "path": "./packages/client/ui-layout" }, From 4eff7510589a1661e114a7cef28b8db1733abe6b Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:13:45 +0800 Subject: [PATCH 063/104] test(client): mount TypeRT remote assembly in fixtures --- apps/web/tests/assembled-boot.ts | 31 ++++++++++--------- apps/web/tests/search-card.snapshot.ts | 4 +-- .../client/runtime/tests/wire-events.spec.ts | 3 ++ 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/apps/web/tests/assembled-boot.ts b/apps/web/tests/assembled-boot.ts index 0e168ba9fe..ebb2aa513a 100644 --- a/apps/web/tests/assembled-boot.ts +++ b/apps/web/tests/assembled-boot.ts @@ -1,5 +1,5 @@ // Shared scaffolding for the assembled-jsdom snapshots: the real built -// `packages/client/*/lib/client.js` artifacts booted through AppWebEntry's +// workspace `lib/client.js` artifacts booted through AppWebEntry's // ModuleLoader path (loadBundle) against the keyless FixtureApiClient // 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 @@ -14,18 +14,21 @@ import { afterEach, beforeEach, vi } from 'vitest' import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client' import { AppWebEntry } from '@deepseek-ai/dsh-client-web' -/** Boot entries for the minimal assembled graph, each carrying the workspace directory its bundle is read from. */ -const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ - { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true }, - { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true }, - { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true }, - { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true }, - { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] }, - { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, - { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, +/** Boot entries for the minimal assembled graph, each carrying the workspace bundle it loads. */ +const PLUGINS: readonly (WebBootEntry & { bundlePath: string })[] = [ + { id: '@deepseek-ai/dsh-typert-registry', bundlePath: 'packages/typert/registry/lib/client.js', url: '/plugins/typert-registry.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-connection', bundlePath: 'packages/client/connection/lib/client.js', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-host-api-gateway', bundlePath: 'packages/host/api-gateway/lib/client.js', url: '/plugins/api-gateway.js', rev: 'fx', inject: ['@deepseek-ai/dsh-typert-registry', '@deepseek-ai/dsh-client-connection'], immediately: true }, + { id: '@deepseek-ai/dsh-client-remotes', bundlePath: 'packages/client/remotes/lib/client.js', url: '/plugins/client-remotes.js', rev: 'fx', inject: ['@deepseek-ai/dsh-host-api-gateway'], immediately: true }, + { id: '@deepseek-ai/dsh-client-runtime', bundlePath: 'packages/client/runtime/lib/client.js', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-client-remotes', '@deepseek-ai/dsh-typert-registry'], immediately: true }, + { id: '@deepseek-ai/dsh-client-ui-theme', bundlePath: 'packages/client/ui-theme/lib/client.js', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-locale', bundlePath: 'packages/client/locale/lib/client.js', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-ui-layout', bundlePath: 'packages/client/ui-layout/lib/client.js', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] }, + { id: '@deepseek-ai/dsh-client-ui-sidebar', bundlePath: 'packages/client/ui-sidebar/lib/client.js', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { id: '@deepseek-ai/dsh-client-ui-conversation', bundlePath: 'packages/client/ui-conversation/lib/client.js', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, { id: '@deepseek-ai/dsh-client-ui-workspace', - dir: 'ui-workspace', + bundlePath: 'packages/client/ui-workspace/lib/client.js', url: '/plugins/ui-workspace.js', rev: 'fx', inject: [ @@ -34,12 +37,12 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ '@deepseek-ai/dsh-client-ui-sidebar', ], }, - { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, + { id: '@deepseek-ai/dsh-client-ui-trajectory', bundlePath: 'packages/client/ui-trajectory/lib/client.js', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, ] const bundles = new Map(PLUGINS.map(plugin => [ plugin.url, - readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'), + readFileSync(join(process.cwd(), plugin.bundlePath), 'utf8'), ])) interface FixtureWindow extends Window { @@ -97,7 +100,7 @@ export function mountAssembledApp(): void { const root = document.createElement('div') root.id = 'root' document.body.appendChild(root) - win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) } + win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ bundlePath: _bundlePath, ...plugin }) => plugin) } act(() => { const entry = new AppWebEntry(root, { loadBundle: async (url) => { diff --git a/apps/web/tests/search-card.snapshot.ts b/apps/web/tests/search-card.snapshot.ts index 626be993a6..8e6322c4af 100644 --- a/apps/web/tests/search-card.snapshot.ts +++ b/apps/web/tests/search-card.snapshot.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom -// Assembled search-card snapshot: boots the real built `packages/client/*/lib/ -// client.js` bundles through AppWebEntry's ModuleLoader path against the keyless +// 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 // session, and pins the search card the `grep` turn (fixture turn 66) renders in // the assembled application. The built-boot smoke proves the graph boots but diff --git a/packages/client/runtime/tests/wire-events.spec.ts b/packages/client/runtime/tests/wire-events.spec.ts index f081eb54c1..5ab644682a 100644 --- a/packages/client/runtime/tests/wire-events.spec.ts +++ b/packages/client/runtime/tests/wire-events.spec.ts @@ -6,6 +6,7 @@ import { Context } from 'cordis' import { describe, expect, it } from 'vitest' import type { ConnectionHandle, ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import * as RuntimeClient from '../src/client/index.ts' import { FakeApiClient } from './fake-api.ts' @@ -16,6 +17,7 @@ interface Bench { async function mount(): Promise { const ctx = new Context() + await ctx.plugin(TypertRegistry) const api = new FakeApiClient() const bench: Bench = { ctx, sinks: undefined } const handle: ConnectionHandle = { @@ -30,6 +32,7 @@ async function mount(): Promise { }, } ctx.reflect.provide('connection', handle) + ctx.reflect.provide('api', {}) await ctx.plugin(RuntimeClient).await() return bench } From 36516e97b970c47b15504f6825d891b1f21bf864 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:41:24 +0800 Subject: [PATCH 064/104] feat(connection): dispatch TypeRT remotes through shared API --- ...08-02-typert-remote-method-calls.i18n.yaml | 4 +- .../2026-08-02-typert-remote-method-calls.md | 73 ++++++----- ...026-08-02-typert-remote-method-calls.zh.md | 73 ++++++----- packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 2 +- packages/client/connection/README.zh.md | 2 +- .../connection/src/api-request-trust.ts | 9 +- packages/client/connection/src/http-bridge.ts | 8 +- packages/client/connection/src/index.ts | 66 +++++----- packages/client/connection/src/rpc-host.ts | 74 ++++++++++- packages/client/connection/src/rpc.ts | 22 +++- .../connection/tests/client-apply.spec.ts | 28 ++--- .../client/connection/tests/node-half.spec.ts | 118 +++++++++++++++--- packages/host/api-gateway/README.i18n.yaml | 4 +- packages/host/api-gateway/README.md | 4 +- packages/host/api-gateway/README.zh.md | 4 +- packages/host/api-gateway/src/client/index.ts | 2 +- packages/host/api-gateway/src/index.ts | 45 ++++--- .../host/api-gateway/tests/client.spec.ts | 6 +- .../host/api-gateway/tests/gateway.spec.ts | 73 +++++++++-- 20 files changed, 439 insertions(+), 182 deletions(-) diff --git a/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index cc2f0736d4..6e7a1a3a13 100644 --- a/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: c3a7a77c583720c3f967de185a089d374f017d81 -2026-08-02-typert-remote-method-calls.zh.md: 9b2fbbd69f1c054cbf6c86f177b743c583be3e8a +2026-08-02-typert-remote-method-calls.md: 61c8f61468621846fa8e8ff78d52313ae805aa17 +2026-08-02-typert-remote-method-calls.zh.md: 1e09965d2baba2db35301288f338cef15d947f36 diff --git a/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.md index c3a7a77c58..61c8f61468 100644 --- a/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.md @@ -20,7 +20,7 @@ A business Service declares callable methods with `@Remote` or `@RemoteContext() The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. The `.d.ts` exposes only methods marked with a Remote decorator and refers to the business package's single public type symbols. The `.d.ts.map` navigates consumer API methods back to their Host business method implementations. The `.js` carries endpoint, parameter, Context, and Zod information for the same contract. At the assembly layer, the Browser Client mounts the required Remote JS contributions onto the Client API Service. The projection and API abstraction remain platform-independent so that a future TUI can reuse them. -`@deepseek-ai/dsh-host-api-gateway`, located at `packages/host/api-gateway`, provides two symmetric faces: its default entry provides Host `ctx.typertGateway`, while its `/client` entry provides consumer-side `ctx.api`. Each side consumes a locally generated `InvocationDescriptor` from the same model; descriptors are not sent over the wire. The Remote data protocol runs over the single Connection/RPC mechanism through an isolated `/api2` channel. The business calling interface does not change when Connection migrates from HTTP to WebSocket. +`@deepseek-ai/dsh-host-api-gateway`, located at `packages/host/api-gateway`, provides two symmetric faces: its default entry provides Host `ctx.typertGateway`, while its `/client` entry provides consumer-side `ctx.api`. Each side consumes a locally generated `InvocationDescriptor` from the same model; descriptors are not sent over the wire. The Remote data protocol runs over Connection's shared `/api` RPC channel. The business calling interface does not change when Connection migrates from HTTP to WebSocket. ## Components and Cordis services @@ -30,7 +30,7 @@ The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. T | TypeRT registry | `ctx.typert` | Separately stores reflection for the current environment, imported Remote contributions, lookup providers, and Context providers | | TypeRT generator/loader | No new business service | Generates three kinds of `lib` artifacts from the Host/Client Programs and registers the current environment's artifacts with `ctx.typert` | | Host API Gateway's Host face | `ctx.typertGateway` | Associates Host definitions with live Services, decodes parameters, resolves receivers, invokes methods, and encodes results | -| Connection | `ctx.connection` | Exclusively owns the HTTP Server/future WebSocket, RPC envelope, rpcId, serialization, trust, and error transport, while carrying the isolated `/api` and `/api2` channels | +| Connection | `ctx.connection` | Exclusively owns the HTTP Server/future WebSocket, the shared `/api` route, RPC envelope, rpcId, serialization, trust, error transport, TypeRT interception, and legacy API Proxy fallback | | Host API Gateway's Client face | `ctx.api` | Mounts Remote contributions, materializes root and scoped APIs, and delegates canonical calls to `ctx.connection.rpc` | | Client Remotes | No new service | Serves as the only Remote facade for Client business code, selecting and mounting `/remote` contributions while exposing the Gateway Client face and the selected API declarations | | Agent/Session owning packages | Existing domain services | Provide both static interface merges and runtime lookup/Context providers | @@ -139,7 +139,7 @@ Parameter order comes from the method signature. HTTP fields come from parameter A LIB codec contains a Zod schema and a canonical `typeSymbol` consisting of "package + public subpath + export name." An SRC codec is marked only as `src-json`. When the Host and consumer run in different JavaScript realms, each holds its own Zod instances, but both sets are generated from the same TypeRT model and symbol keys. -Descriptors exist only in the local registry on each side. The wire carries only the `/api2` channel, endpoint, and `{ args }` payload. The Host uses its descriptor to decode and invoke the method, while the Client uses its corresponding descriptor to encode arguments and validate the result. +Descriptors exist only in the local registry on each side. The wire carries only the `/api` channel, endpoint, and `{ args }` payload. The Host uses its descriptor to decode and invoke the method, while the Client uses its corresponding descriptor to encode arguments and validate the result. ## TypeRT runtime registry @@ -294,20 +294,20 @@ Client business packages depend only on `@deepseek-ai/dsh-client-remotes/client` `ctx.api.mount()` registers a contribution with `TypeRT.remotes`, and its disposer is owned by the Cordis fiber that called the method. Duplicate endpoints, conflicting invocation modes for the same namespace and method, or conflicts between a descriptor and an existing type identity fail immediately. -The API Service materializes each `@Remote` descriptor as a real function on the root `api`. The function constructs named `args` in descriptor parameter order, applies the Client's strict codec, and then calls `ctx.connection.rpc.call('/api2', endpoint, { args })`. +The API Service materializes each `@Remote` descriptor as a real function on the root `api`. The function constructs named `args` in descriptor parameter order, applies the Client's strict codec, and then calls `ctx.connection.rpc.call('/api', endpoint, { args })`. -Neither a direct descriptor with `scope` nor a `@RemoteContext` descriptor copies functions into every Agent Scope. The API Service creates one root singleton Cordis Service for each scoped namespace and materializes methods on that Service. When `agent.goals.create()` is called, the Cordis tracker rebinds the Service's `this.ctx` to the current Agent Context. The method then asks the corresponding Context binder for identity from `this.ctx`. A direct scoped projection substitutes this identity at the lookup position named by `scope.wire`; a Context descriptor writes the identity into the receiver's separate wire field. Both issue the same kind of `/api2` call. +Neither a direct descriptor with `scope` nor a `@RemoteContext` descriptor copies functions into every Agent Scope. The API Service creates one root singleton Cordis Service for each scoped namespace and materializes methods on that Service. When `agent.goals.create()` is called, the Cordis tracker rebinds the Service's `this.ctx` to the current Agent Context. The method then asks the corresponding Context binder for identity from `this.ctx`. A direct scoped projection substitutes this identity at the lookup position named by `scope.wire`; a Context descriptor writes the identity into the receiver's separate wire field. Both issue the same kind of `/api` call. ```text root ctx.api.goals.create(agentId, request) → direct descriptor - → ctx.connection.rpc.call('/api2', 'goals/create', { args }) + → ctx.connection.rpc.call('/api', 'goals/create', { args }) agent.goals.create(request) → tracker 将 namespace Service rebind 到 agent Context → agent binder 从 caller Context 取得 agentId → 用 agentId 补入同一 direct descriptor 的 lookup 参数 - → ctx.connection.rpc.call('/api2', 'goals/create', { args }) + → ctx.connection.rpc.call('/api', 'goals/create', { args }) ``` The Root `Context` does not merge the scoped `goals` type; only `AgentContext` gains that property through `RemoteContextApi<'agent'>`. If a caller bypasses the type system and dynamically calls a scoped method from Root, the binder reports an explicit error. If the Client already has a Cordis service with the same name, or two contributions claim the same namespace and method incompatibly, mounting fails instead of overwriting the existing service. @@ -318,7 +318,7 @@ Generated Remote JS contains only descriptors, symbol keys, and codecs; it does Remote API is a consumer capability, not a synonym for Browser API. This phase implements only Browser Client contribution mounting, Connection RPC calls, and Agent Scope association. -Remote DTS, Remote JS, `RemoteApi`, `InvocationDescriptor`, the Remote RPC data protocol, and Context binders must not depend on the DOM, Browser module loaders, or HTTP. Through Connection, the Browser Client encodes descriptor-materialized methods as `/api2` RPC calls. +Remote DTS, Remote JS, `RemoteApi`, `InvocationDescriptor`, the Remote RPC data protocol, and Context binders must not depend on the DOM, Browser module loaders, or HTTP. Through Connection, the Browser Client encodes descriptor-materialized methods as `/api` RPC calls. A future TUI can join the same call abstraction without changing business decorators, Remote maps, or the shape of API calls. The TUI-visible API must still be generated exclusively from `@Remote` and `@RemoteContext`; sharing a process with the Host must not allow it to bypass Remote restrictions and expose Service methods directly. @@ -363,22 +363,28 @@ ctx.typertGateway.invoke({ namespace, method, args }) `ctx.typertGateway.invoke()` is the carrier-independent Host entry point. It neither creates an rpcId, RPC envelope, nor HTTP response. It returns only the encoded result or raises a Gateway error that the Connection RPC adapter maps for transport. -## The `/api2` call chain +## The shared `/api` call chain -`/api2` is an isolated protocol channel on the single Connection/RPC mechanism, not a transport created by the Gateway. The Gateway registers one local handler with Connection. This phase adds the following general channel capability to the existing HTTP Connection: +Connection owns one `/api` route on the HTTP Server. The Gateway mounts a synchronous endpoint ownership test and the Remote RPC handler into Connection: ```text -ctx.connection.rpc.handle('/api2', (endpoint, payload) => { - const { namespace, method } = parseEndpoint(endpoint) - const { args } = parsePayload(payload) - return ctx.typertGateway.invoke({ namespace, method, args }) -}) +ctx.connection.rpc.intercept( + '/api', + endpoint => ownsRemoteEndpoint(endpoint), + (endpoint, payload) => { + const { namespace, method } = parseEndpoint(endpoint) + const { args } = parsePayload(payload) + return ctx.typertGateway.invoke({ namespace, method, args }) + }, +) ``` -The Connection Host half obtains a handle from the single HTTP Server and reuses the same RPC bridge, request/response envelope, rpcId, serialization, trust, transport errors, and `RpcError`. Its current physical mapping is: +The Gateway claims an endpoint when the Host registry contains its strict descriptor, remembers a withdrawn strict descriptor, or finds a matching `@Remote` marker on an active SRC Service binding. A claimed endpoint stays in the Gateway after payload decoding, descriptor resolution, or invocation fails; only an endpoint that is not Remote-owned reaches the legacy API Proxy fallback. + +The Connection Host half passes one composite FetchHandler to the HTTP bridge. After the bridge creates a standard `Request`, that handler selects either the Gateway RPC FetchHandler or the API Proxy FetchHandler. Both paths reuse the same request/response envelope, rpcId, serialization, trust, transport errors, and `RpcError`. The current physical mapping is: ```text -POST /api2// +POST /api// ``` The Remote payload is a named JSON object, not a positional array, and does not carry an `InvocationDescriptor`. A normal Goal call has this payload slot: @@ -399,11 +405,12 @@ The complete path is: ```text ctx.api.goals.create(sessionId, request) → Client InvocationDescriptor 编码 { args: { agentId, request } } -→ ctx.connection.rpc.call('/api2', 'goals/create', { args }) +→ ctx.connection.rpc.call('/api', 'goals/create', { args }) → Connection 创建 rpcId 和既有 client-request envelope -→ 当前 carrier 发送 POST /api2/goals/create -→ Connection Host half 执行 trust、反序列化和 RPC 分发 -→ /api2 handler 调用 ctx.typertGateway.invoke(...) +→ 当前 carrier 发送 POST /api/goals/create +→ Connection Host half 执行共享 trust,再由 bridge 创建标准 Request +→ 复合 FetchHandler 判断 endpoint ownership 并选择目标 FetchHandler +→ TypeRT interceptor 调用 ctx.typertGateway.invoke(...) → Host InvocationDescriptor 解码、lookup、receiver 解析和 Reflect.apply → result codec 编码 → Connection 写入既有 RPC result 并回送相同 rpcId @@ -412,30 +419,30 @@ ctx.api.goals.create(sessionId, request) Remote does not define a second-layer `{ ok, value/error }` response. Successful values and Gateway errors use the existing RPC response's `result` directly. The Gateway adapter maps endpoint, schema, lookup, Context, Service, and business-invocation failures to `RpcError`; Connection transports that error. -The Gateway does not handle per-method permissions, caller identity, cancellation, idempotency, or long-lived connection state. This work only extends Connection with general channel registration and invocation capabilities. It does not change existing `/api`, trusted connection, trusted-host, or privileged-method semantics. Connection's WebSocket migration remains separate follow-up work. +The Gateway does not handle per-method permissions, caller identity, cancellation, idempotency, or long-lived connection state. TypeRT endpoints use Connection's trusted-host policy; unclaimed endpoints retain the legacy API Proxy's trust and privileged-method policies. Connection's WebSocket migration remains separate follow-up work. ## Connection and protocol boundaries -The API Service owns Remote contributions, method materialization, Scope binding, and the correspondence between positional parameters and descriptors. The Gateway owns Host descriptors, lookup, Context, and business invocation. Connection only sends `/api2`, the endpoint, and `{ args }` as one RPC call to the target and returns the existing RPC result; it does not understand Goal, Agent, lookup, descriptors, or Client API types. +The API Service owns Remote contributions, method materialization, Scope binding, and the correspondence between positional parameters and descriptors. The Gateway owns Host descriptors, endpoint ownership, lookup, Context, and business invocation. Connection sends `/api`, the endpoint, and `{ args }` as one RPC call to the target and returns the existing RPC result; it does not understand Goal, Agent, lookup, descriptors, or Client API types. -`/api` and `/api2` share one Connection, Server, RPC envelope, and connection lifecycle while remaining separate protocols. When Connection migrates from HTTP to WebSocket, `/api2` naturally changes from a physical path to a logical channel. The Remote payload, business decorators, generated DTS, Remote API types, and Agent Scope programming interface remain unchanged. +The Gateway registers only its ownership matcher and RPC handler with Connection; it does not register an HTTP route. Connection mounts the shared `/api` route into the HTTP Server and gives the bridge one composite FetchHandler; that handler dispatches claimed endpoints to Gateway and unclaimed endpoints to API Proxy. A future Connection transport can preserve this order without changing the Remote payload, business decorators, generated DTS, Remote API types, or Agent Scope programming interface. ## Package boundaries - `@deepseek-ai/dsh-type-meta`: lightweight protocols for decorators, bindings, lookup, Remote Context, and descriptors. - TypeRT generator: analyzes Host/Client Programs, generates local faces and Remote consumer projections, and emits canonical symbol/Zod information. - TypeRT runtime: separately stores the current environment's local reflection and imported Remote contributions. -- `@deepseek-ai/dsh-host-api-gateway`: its default entry associates Host definitions with Services, performs lookup, resolves Context receivers, invokes methods, encodes results, and registers an `/api2` handler with Connection; its `/client` entry mounts Remote contributions, creates strict API methods, and delegates calls to `ctx.connection.rpc`. The entries share the Remote protocol but do not import each other's Cordis interface merges. +- `@deepseek-ai/dsh-host-api-gateway`: its default entry associates Host definitions with Services, claims Remote endpoints, performs lookup, resolves Context receivers, invokes methods, encodes results, and registers an `/api` interceptor with Connection; its `/client` entry mounts Remote contributions, creates strict API methods, and delegates calls to `ctx.connection.rpc`. The entries share the Remote protocol but do not import each other's Cordis interface merges. - `@deepseek-ai/dsh-client-remotes`: the only Remote facade depended on by Client business code; directly depends on the Gateway Client face, selects `/remote` contributions, and exposes the merged API types to business packages. -- Connection: owns the single HTTP Server/future WebSocket carrier, RPC envelope, rpcId, serialization, trust, and error transport while carrying the isolated `/api` and `/api2` channels. +- Connection: owns the single HTTP Server/future WebSocket carrier, shared `/api` route and composite FetchHandler, API Proxy fallback, RPC envelope, rpcId, serialization, trust, and error transport. - Business-object packages such as Agent/Session: own lookup, Context providers, canonical ID types, and public type-only entries. - Business Service packages: declare bindings, Remote methods, and their request/result types, and export the generated `/remote` subpath. ## Initial implementation scope -The first vertical path implements `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api2 → Host Gateway → GoalService.remoteExportCreate()` and proves that the same direct descriptor with an Agent lookup supports both `ctx.api.goals.create(agentId, request)` and `agentCtx.goals.create(request)`. The scoped-receiver semantics of `@RemoteContext('agent')` remain a separate mode. +The first vertical path implements `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()` and proves that the same direct descriptor with an Agent lookup supports both `ctx.api.goals.create(agentId, request)` and `agentCtx.goals.create(request)`. The scoped-receiver semantics of `@RemoteContext('agent')` remain a separate mode. -This phase implements Connection's general second-channel API and its current HTTP carrier mapping, but not WebSocket migration, the TUI runtime, a TUI carrier, or TUI Agent Scope wiring. This RFC also does not design Permission/Approval state machines, Session event streams, call authorization, cancellation, retries, idempotency, or cross-version protocol compatibility. +This phase implements Connection's shared-channel interceptor and current HTTP carrier mapping, but not WebSocket migration, the TUI runtime, a TUI carrier, or TUI Agent Scope wiring. This RFC also does not design Permission/Approval state machines, Session event streams, call authorization, cancellation, retries, idempotency, or cross-version protocol compatibility. ## Alternatives considered @@ -455,7 +462,7 @@ This phase implements Connection's general second-channel API and its current HT **Let a top-level `/remote` import register global state implicitly.** The target Cordis Context may not exist when ESM evaluation occurs, and ownership becomes ambiguous across multiple Contexts, HMR, and disposal. A normal value import therefore returns only a contribution, which the environment assembly explicitly mounts through the API Service. -**Create a separate transport, HTTP route, and response envelope for Remote.** This would duplicate the existing Connection's Server ownership, rpcId, serialization, trust, errors, and future WebSocket lifecycle, requiring two RPC stacks to migrate separately. `/api2` instead reuses the single Connection/RPC mechanism as an isolated protocol channel. +**Create a separate transport, HTTP route, or `/api2` channel for Remote.** This would duplicate or split Connection's Server ownership, rpcId, serialization, trust, errors, and future WebSocket lifecycle. The shared `/api` interceptor instead keeps one physical route and lets Connection preserve API Proxy as the fallback FetchHandler. ## Acceptance criteria @@ -465,10 +472,10 @@ This phase implements Connection's general second-channel API and its current HT - After the Client assembly mounts the JS contribution obtained from the same import, TypeRT can reflect endpoint, parameter, result, lookup, Context, and Zod information, and the API Service creates the calling method without a hand-written stub. - Remote DTS, Remote JS, `RemoteApi`, and the descriptor protocol do not depend on Browser-specific capabilities, and the type model cannot expose unmarked Goal Service methods, preserving the boundary required for future isomorphic TUI integration. - `agent.goals.*` obtains its call Scope through the Cordis tracker and Context binder. The Root Context has no Agent-only type, and functions are not copied into each Scope. -- `/api2/goals/create` resolves `agentId` to the canonical Agent object, invokes the original Goal Service receiver, and returns the result through the existing RPC result/error mechanism. -- `/api2` and `/api` share the single Connection/RPC carrier while remaining protocol-isolated. Remote neither registers an HTTP Server handle directly nor defines a second response envelope. -- Connection provides general channel registration and invocation capabilities and maps `/api2` to the current HTTP carrier. Existing `/api` behavior and trust semantics remain unchanged. -- This implementation does not change existing `/api`, Connection/trusted connection, Permission/Approval, or Session event stream behavior. +- `/api/goals/create` resolves `agentId` to the canonical Agent object, invokes the original Goal Service receiver, and returns the result through the existing RPC result/error mechanism. +- Gateway mounts into Connection, Connection mounts the single `/api` route into HTTP Server, and Remote defines neither an HTTP route nor a second response envelope. +- Connection's composite FetchHandler dispatches a TypeRT-owned endpoint to Gateway and falls back to API Proxy only when Gateway does not claim it. A withdrawn strict endpoint remains claimed and fails as unavailable. +- Existing API Proxy trust, privileged-method, Permission/Approval, and Session event stream behavior remains unchanged for unclaimed endpoints. ## Risks diff --git a/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.zh.md index 9b2fbbd69f..1e09965d2b 100644 --- a/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -20,7 +20,7 @@ Host 与 Browser Client 使用独立的 TypeScript Program,因为两边会以 Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只暴露被 Remote decorator 标记的方法,并引用业务包唯一的公共类型符号;`.d.ts.map` 把消费端 API 方法导航回 Host 业务方法实现;`.js` 携带同一契约的 endpoint、参数、Context 和 Zod 信息。Browser Client 在 assembly 层把需要的 Remote JS 贡献集中挂到 Client API Service;该投影和 API 抽象保持平台无关,以便未来 TUI 复用。 -`@deepseek-ai/dsh-host-api-gateway` 在 `packages/host/api-gateway` 内提供对称的两个 face:默认入口提供 Host `ctx.typertGateway`,`/client` 入口提供消费端 `ctx.api`。两边各自在本地消费由同一模型生成的 `InvocationDescriptor`,descriptor 不通过 wire 发送。Remote 数据协议运行在唯一 Connection/RPC 机制之上,使用独立 `/api2` channel;业务调用界面不随 Connection 从 HTTP 迁移到 WebSocket 而改变。 +`@deepseek-ai/dsh-host-api-gateway` 在 `packages/host/api-gateway` 内提供对称的两个 face:默认入口提供 Host `ctx.typertGateway`,`/client` 入口提供消费端 `ctx.api`。两边各自在本地消费由同一模型生成的 `InvocationDescriptor`,descriptor 不通过 wire 发送。Remote 数据协议运行在 Connection 共享的 `/api` RPC channel 上;业务调用界面不随 Connection 从 HTTP 迁移到 WebSocket 而改变。 ## 组件和 Cordis 服务 @@ -30,7 +30,7 @@ Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只 | TypeRT registry | `ctx.typert` | 分开保存当前环境 reflection、导入的 Remote contribution、lookup provider 和 Context provider | | TypeRT generator/loader | 无新增业务服务 | 从 Host/Client Program 生成三类 `lib` 产物,并把当前环境产物注册到 `ctx.typert` | | Host API Gateway 的 Host face | `ctx.typertGateway` | 关联 Host definition 与活 Service,解码参数、解析 receiver、调用方法和编码结果 | -| Connection | `ctx.connection` | 独占 HTTP Server/未来 WebSocket、RPC envelope、rpcId、序列化、trust 和错误传输,并承载 `/api` 与 `/api2` 两个隔离 channel | +| Connection | `ctx.connection` | 独占 HTTP Server/未来 WebSocket、共享 `/api` route、RPC envelope、rpcId、序列化、trust、错误传输、TypeRT 拦截和旧 API Proxy 回退 | | Host API Gateway 的 Client face | `ctx.api` | mount Remote contribution,实体化根 API 和 scoped API,把规范调用交给 `ctx.connection.rpc` | | Client Remotes | 无新增服务 | 作为 Client 业务的唯一 Remote facade,选择并挂载 `/remote` contribution,同时传递 Gateway Client face 和所选 API 的类型声明 | | Agent/Session owning 包 | 既有领域服务 | 同时提供静态 interface merge 与运行时 lookup/Context provider | @@ -139,7 +139,7 @@ InvocationDescriptor { LIB codec 带有 Zod schema 和“package + 公共 subpath + export name”的规范 `typeSymbol`;SRC codec 只标记 `src-json`。Host 和消费端运行在不同 JavaScript realm 时会各自持有 Zod 实例,但这些实例由同一 TypeRT 模型和 symbol key 生成。 -descriptor 只存在于两端本地 registry。wire 上只有 `/api2` channel、endpoint 和 `{ args }` payload;Host 用自己的 descriptor 解码和调用,Client 用自己的对应 descriptor 编码参数和验证结果。 +descriptor 只存在于两端本地 registry。wire 上只有 `/api` channel、endpoint 和 `{ args }` payload;Host 用自己的 descriptor 解码和调用,Client 用自己的对应 descriptor 编码参数和验证结果。 ## TypeRT 运行时 registry @@ -294,20 +294,20 @@ Client 业务包只引用 `@deepseek-ai/dsh-client-remotes/client`,不直接 `ctx.api.mount()` 把 contribution 注册到 `TypeRT.remotes`,并由调用该方法的 Cordis fiber 持有 disposer。endpoint 重复、同一 namespace/method 模式冲突或 descriptor 与现有类型身份冲突时直接失败。 -API Service 把 `@Remote` descriptor 实体化为根 `api` 上的真实函数。函数按 descriptor 的位置参数顺序构造具名 `args`,执行 Client strict codec,然后调用 `ctx.connection.rpc.call('/api2', endpoint, { args })`。 +API Service 把 `@Remote` descriptor 实体化为根 `api` 上的真实函数。函数按 descriptor 的位置参数顺序构造具名 `args`,执行 Client strict codec,然后调用 `ctx.connection.rpc.call('/api', endpoint, { args })`。 -带 `scope` 的 direct descriptor 和 `@RemoteContext` descriptor 都不为每个 Agent Scope 复制函数。API Service 为每个 scoped namespace 建立一个 root singleton Cordis Service,并在该 Service 上实体化方法;Cordis tracker 在 `agent.goals.create()` 调用时把 Service 的 `this.ctx` rebind 到当前 Agent Context。方法再通过对应 Context binder 从 `this.ctx` 取得 identity。direct scoped 投影用 identity 替代 `scope.wire` 指定的 lookup 位置,Context descriptor 则把 identity 写入 receiver 的独立 wire 字段;两者都发起同一种 `/api2` 调用。 +带 `scope` 的 direct descriptor 和 `@RemoteContext` descriptor 都不为每个 Agent Scope 复制函数。API Service 为每个 scoped namespace 建立一个 root singleton Cordis Service,并在该 Service 上实体化方法;Cordis tracker 在 `agent.goals.create()` 调用时把 Service 的 `this.ctx` rebind 到当前 Agent Context。方法再通过对应 Context binder 从 `this.ctx` 取得 identity。direct scoped 投影用 identity 替代 `scope.wire` 指定的 lookup 位置,Context descriptor 则把 identity 写入 receiver 的独立 wire 字段;两者都发起同一种 `/api` 调用。 ```text root ctx.api.goals.create(agentId, request) → direct descriptor - → ctx.connection.rpc.call('/api2', 'goals/create', { args }) + → ctx.connection.rpc.call('/api', 'goals/create', { args }) agent.goals.create(request) → tracker 将 namespace Service rebind 到 agent Context → agent binder 从 caller Context 取得 agentId → 用 agentId 补入同一 direct descriptor 的 lookup 参数 - → ctx.connection.rpc.call('/api2', 'goals/create', { args }) + → ctx.connection.rpc.call('/api', 'goals/create', { args }) ``` Root `Context` 不 merge scoped `goals` 类型;只有 `AgentContext` 通过 `RemoteContextApi<'agent'>` 获得该属性。若调用方绕过类型从 Root 动态调用 scoped 方法,binder 明确报错。若 Client 已有同名 Cordis service,或两个 contribution 冲突占用同一 namespace/method,mount 直接失败,不覆盖现有服务。 @@ -318,7 +318,7 @@ Root `Context` 不 merge scoped `goals` 类型;只有 `AgentContext` 通过 `R Remote API 是消费端能力,不等同于 Browser API。本期只实现 Browser Client 的 contribution 挂载、Connection RPC 调用和 Agent Scope 关联。 -Remote DTS、Remote JS、`RemoteApi`、`InvocationDescriptor`、Remote RPC 数据协议和 Context binder 不得依赖 DOM、Browser module loader 或 HTTP。Browser Client 通过 Connection 把 descriptor 实体化的方法编码为 `/api2` RPC 调用。 +Remote DTS、Remote JS、`RemoteApi`、`InvocationDescriptor`、Remote RPC 数据协议和 Context binder 不得依赖 DOM、Browser module loader 或 HTTP。Browser Client 通过 Connection 把 descriptor 实体化的方法编码为 `/api` RPC 调用。 未来 TUI 可以在不改变业务 decorator、Remote maps 和 API 调用形状的前提下接入同一调用抽象。届时 TUI 可见的 API 仍只能由 `@Remote` 和 `@RemoteContext` 生成,不能因为它与 Host 同进程就绕过 Remote 限制直接暴露 Service 方法。 @@ -363,22 +363,28 @@ ctx.typertGateway.invoke({ namespace, method, args }) `ctx.typertGateway.invoke()` 是 carrier-independent 的 Host 入口。它不创建 rpcId、RPC envelope 或 HTTP response;它只返回编码结果,或产生由 Connection RPC adapter 映射的 Gateway 错误。 -## `/api2` 调用链 +## 共享 `/api` 调用链 -`/api2` 是唯一 Connection/RPC 机制上的独立协议 channel,不是 Gateway 自建的 transport。Gateway 只向 Connection 注册一个本地 handler;本期在现有 HTTP Connection 中增加这项通用 channel 能力: +Connection 在 HTTP Server 上持有唯一 `/api` route。Gateway 把同步 endpoint ownership 判断和 Remote RPC handler 挂到 Connection: ```text -ctx.connection.rpc.handle('/api2', (endpoint, payload) => { - const { namespace, method } = parseEndpoint(endpoint) - const { args } = parsePayload(payload) - return ctx.typertGateway.invoke({ namespace, method, args }) -}) +ctx.connection.rpc.intercept( + '/api', + endpoint => ownsRemoteEndpoint(endpoint), + (endpoint, payload) => { + const { namespace, method } = parseEndpoint(endpoint) + const { args } = parsePayload(payload) + return ctx.typertGateway.invoke({ namespace, method, args }) + }, +) ``` -Connection Host half 从唯一 HTTP Server 取得 handle,复用同一 RPC bridge、request/response envelope、rpcId、序列化、trust、transport error 和 `RpcError`。当前物理映射是: +Host registry 中存在 strict descriptor、记录过已撤回的 strict descriptor,或 active SRC Service binding 上存在匹配的 `@Remote` 标记时,Gateway 认领该 endpoint。endpoint 一旦被认领,即使 payload 解码、descriptor 解析或调用失败也继续由 Gateway 返回错误;只有不属于 Remote 的 endpoint 才进入旧 API Proxy 回退。 + +Connection Host half 把一个复合 FetchHandler 交给 HTTP bridge。bridge 创建标准 `Request` 后,该 handler 再选择 Gateway RPC FetchHandler 或 API Proxy FetchHandler;两条路径复用同一 request/response envelope、rpcId、序列化、trust、transport error 和 `RpcError`。当前物理映射是: ```text -POST /api2// +POST /api// ``` Remote payload 使用具名 JSON 对象,不使用位置数组,也不发送 `InvocationDescriptor`。普通 Goal 调用的 payload slot 是: @@ -399,11 +405,12 @@ Remote payload 使用具名 JSON 对象,不使用位置数组,也不发送 ` ```text ctx.api.goals.create(sessionId, request) → Client InvocationDescriptor 编码 { args: { agentId, request } } -→ ctx.connection.rpc.call('/api2', 'goals/create', { args }) +→ ctx.connection.rpc.call('/api', 'goals/create', { args }) → Connection 创建 rpcId 和既有 client-request envelope -→ 当前 carrier 发送 POST /api2/goals/create -→ Connection Host half 执行 trust、反序列化和 RPC 分发 -→ /api2 handler 调用 ctx.typertGateway.invoke(...) +→ 当前 carrier 发送 POST /api/goals/create +→ Connection Host half 执行共享 trust,再由 bridge 创建标准 Request +→ 复合 FetchHandler 判断 endpoint ownership 并选择目标 FetchHandler +→ TypeRT interceptor 调用 ctx.typertGateway.invoke(...) → Host InvocationDescriptor 解码、lookup、receiver 解析和 Reflect.apply → result codec 编码 → Connection 写入既有 RPC result 并回送相同 rpcId @@ -412,30 +419,30 @@ ctx.api.goals.create(sessionId, request) Remote 不定义第二层 `{ ok, value/error }` response。成功值和 Gateway 错误直接使用既有 RPC response 的 `result`;Gateway adapter 负责把 endpoint、schema、lookup、Context、Service 和业务调用失败映射为 `RpcError`,Connection 负责传输该错误。 -Gateway 不处理逐方法权限、调用者身份、取消、幂等或长连接状态。本工作只扩展 Connection 的通用 channel 注册和调用能力,不改变现有 `/api`、trusted connection、trusted-host 或 privileged method 语义;Connection/WebSocket 迁移后续独立完成。 +Gateway 不处理逐方法权限、调用者身份、取消、幂等或长连接状态。TypeRT endpoint 使用 Connection 的 trusted-host 策略;未认领 endpoint 保留旧 API Proxy 的 trust 和 privileged-method 策略。Connection/WebSocket 迁移后续独立完成。 ## Connection 与协议边界 -API Service 负责 Remote contribution、方法实体化、Scope 绑定以及位置参数与 descriptor 的对应。Gateway 负责 Host descriptor、lookup、Context 和业务调用。Connection 只负责把 `/api2`、endpoint 和 `{ args }` 作为一个 RPC 调用发送到目标并返回既有 RPC result;它不理解 Goal、Agent、lookup、descriptor 或 Client API 类型。 +API Service 负责 Remote contribution、方法实体化、Scope 绑定以及位置参数与 descriptor 的对应。Gateway 负责 Host descriptor、endpoint ownership、lookup、Context 和业务调用。Connection 把 `/api`、endpoint 和 `{ args }` 作为一个 RPC 调用发送到目标并返回既有 RPC result;它不理解 Goal、Agent、lookup、descriptor 或 Client API 类型。 -`/api` 与 `/api2` 共享唯一 Connection、Server、RPC envelope 和连接生命周期,但保持协议隔离。Connection 从 HTTP 迁移到 WebSocket 时,`/api2` 从物理路径自然变成逻辑 channel;Remote payload、业务 decorator、生成的 DTS、Remote API 类型和 Agent Scope 编程界面都不变化。 +Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 HTTP route。Connection 把共享 `/api` route 挂到 HTTP Server,并把一个复合 FetchHandler 交给 bridge;该 handler 将已认领 endpoint 分发给 Gateway,未认领 endpoint 则交给 API Proxy。未来 Connection transport 可以保留相同顺序,而不改变 Remote payload、业务 decorator、生成的 DTS、Remote API 类型或 Agent Scope 编程界面。 ## 包边界 - `@deepseek-ai/dsh-type-meta`:轻量 decorator、binding、lookup、Remote Context 和 descriptor 协议。 - TypeRT generator:分析 Host/Client Program,生成本地 face 和 Remote 消费端投影,并生成规范 symbol/Zod 信息。 - TypeRT runtime:分别保存当前环境的 local reflection 与导入的 Remote contribution。 -- `@deepseek-ai/dsh-host-api-gateway`:默认入口关联 Host definition 与 Service,执行 lookup、Context receiver 解析、调用和结果编码,并向 Connection 注册 `/api2` handler;`/client` 入口挂载 Remote contribution,创建严格 API 方法,并把调用交给 `ctx.connection.rpc`。两个入口共享 Remote 协议,但不互相导入各自的 Cordis interface merge。 +- `@deepseek-ai/dsh-host-api-gateway`:默认入口关联 Host definition 与 Service,认领 Remote endpoint,执行 lookup、Context receiver 解析、调用和结果编码,并向 Connection 注册 `/api` interceptor;`/client` 入口挂载 Remote contribution,创建严格 API 方法,并把调用交给 `ctx.connection.rpc`。两个入口共享 Remote 协议,但不互相导入各自的 Cordis interface merge。 - `@deepseek-ai/dsh-client-remotes`:Client 业务唯一依赖的 Remote facade;直接依赖 Gateway Client face,选择 `/remote` contributions,并向业务包传递合并后的 API 类型。 -- Connection:拥有唯一 HTTP Server/未来 WebSocket carrier、RPC envelope、rpcId、序列化、trust 和错误传输,同时承载隔离的 `/api` 与 `/api2` channel。 +- Connection:拥有唯一 HTTP Server/未来 WebSocket carrier、共享 `/api` route 与复合 FetchHandler、API Proxy 回退、RPC envelope、rpcId、序列化、trust 和错误传输。 - Agent/Session 等业务对象包:拥有 lookup、Context provider、唯一 ID 类型和纯类型公共出口。 - 业务 Service 包:声明 binding、Remote 方法及其 request/result 类型,并导出生成的 `/remote` 子路径。 ## 首期实现范围 -第一条纵向链路实现 `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api2 → Host Gateway → GoalService.remoteExportCreate()`,并证明同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.api.goals.create(agentId, request)` 与 `agentCtx.goals.create(request)`。`@RemoteContext('agent')` 的 scoped receiver 语义继续保留为独立模式。 +第一条纵向链路实现 `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`,并证明同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.api.goals.create(agentId, request)` 与 `agentCtx.goals.create(request)`。`@RemoteContext('agent')` 的 scoped receiver 语义继续保留为独立模式。 -本期实现 Connection 的通用第二 channel API 及当前 HTTP carrier 映射,但不实现 WebSocket 迁移、TUI runtime、TUI carrier 或 TUI Agent Scope 接线。本 RFC 也不设计 Permission/Approval 状态机、Session 事件流、调用授权、取消、重试、幂等和跨版本协议兼容。 +本期实现 Connection 的共享 channel interceptor 及当前 HTTP carrier 映射,但不实现 WebSocket 迁移、TUI runtime、TUI carrier 或 TUI Agent Scope 接线。本 RFC 也不设计 Permission/Approval 状态机、Session 事件流、调用授权、取消、重试、幂等和跨版本协议兼容。 ## Alternatives considered @@ -455,7 +462,7 @@ API Service 负责 Remote contribution、方法实体化、Scope 绑定以及位 **让 `/remote` 的顶层 import 偷偷注册全局状态。** ESM 求值时未必已有目标 Cordis Context,多个 Context、HMR 和 dispose 也无法明确归属,因此普通 value import 只返回 contribution,由环境 assembly 的 API Service 显式挂载。 -**为 Remote 新建独立 transport、HTTP route 和响应信封。** 这会复制现有 Connection 的 Server ownership、rpcId、序列化、trust、错误和未来 WebSocket 生命周期,并让两个 RPC 栈分别迁移,因此 `/api2` 作为独立协议 channel 复用唯一 Connection/RPC 机制。 +**为 Remote 新建独立 transport、HTTP route 或 `/api2` channel。** 这会复制或拆分 Connection 的 Server ownership、rpcId、序列化、trust、错误和未来 WebSocket 生命周期。共享 `/api` interceptor 保留唯一物理 route,并让 Connection 继续以 API Proxy 作为回退 FetchHandler。 ## Acceptance criteria @@ -465,10 +472,10 @@ API Service 负责 Remote contribution、方法实体化、Scope 绑定以及位 - Client assembly 挂载同一个 import 得到的 JS contribution 后,TypeRT 能反射 endpoint、参数、结果、lookup、Context 和 Zod 信息,API Service 无需手写 stub 即可创建调用方法。 - Remote DTS、Remote JS、`RemoteApi` 和 descriptor 协议不依赖 Browser 专属能力,且类型模型无法暴露未标记的 Goal Service 方法,为未来 TUI 同构接入保留边界。 - `agent.goals.*` 通过 Cordis tracker 和 Context binder 取得调用 Scope,Root Context 不获得 Agent-only 类型,且不为每个 Scope 复制函数。 -- `/api2/goals/create` 能把 `agentId` 解析为唯一 Agent 对象,调用原始 Goal Service receiver,并通过既有 RPC result/error 返回结果。 -- `/api2` 与 `/api` 共享唯一 Connection/RPC carrier,但保持协议隔离;Remote 不直接注册 HTTP Server handle,也不定义第二套 response envelope。 -- Connection 提供通用 channel 注册和调用能力,并把 `/api2` 映射到当前 HTTP carrier;现有 `/api` 行为与 trust 语义保持不变。 -- 现有 `/api`、Connection/trusted connection、Permission/Approval 和 Session 事件流行为不因本实现改变。 +- `/api/goals/create` 能把 `agentId` 解析为唯一 Agent 对象,调用原始 Goal Service receiver,并通过既有 RPC result/error 返回结果。 +- Gateway 挂到 Connection,Connection 把唯一 `/api` route 挂到 HTTP Server;Remote 不定义 HTTP route 或第二套 response envelope。 +- Connection 的复合 FetchHandler 将 TypeRT 认领的 endpoint 分发给 Gateway,仅在 Gateway 不认领时回退 API Proxy;已撤回的 strict endpoint 继续被认领并返回 unavailable。 +- 未认领 endpoint 保留既有 API Proxy trust、privileged-method、Permission/Approval 和 Session 事件流行为。 ## Risks diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 05b9bb4141..ddfda12f4e 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: 1393e79aacecbbf7b186f19e4c42269595854b0e -README.zh.md: 70380ceba1b16b2970e947fb6cd9b2af9085ae51 +README.md: 161e34c4b6018625fb690e178eb9a9f8ac0ef21b +README.zh.md: d17012cc89c02a1b11f16d126b7c0cafe67fb2a0 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 1393e79aac..161e34c4b6 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md); the protocol contract is api-contracts v3 §3. +Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. The Host half owns the single `/api` route and its Fetch bridge; a registered TypeRT interceptor claims its Remote endpoints before the API Proxy fallback. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md); the protocol contract is api-contracts v3 §3. ## /api browser-trust fence diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 70380ceba1..d17012cc89 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md);协议契约见 api-contracts v3 §3。 +协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。Host half 持有唯一 `/api` route 及其 Fetch bridge;已注册的 TypeRT interceptor 会先认领自己的 Remote endpoint,未认领请求再回退 API Proxy。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md);协议契约见 api-contracts v3 §3。 ## /api 浏览器信任栅栏 diff --git a/packages/client/connection/src/api-request-trust.ts b/packages/client/connection/src/api-request-trust.ts index 4e897ccf87..141092c63b 100644 --- a/packages/client/connection/src/api-request-trust.ts +++ b/packages/client/connection/src/api-request-trust.ts @@ -16,12 +16,13 @@ import type { IncomingHttpHeaders } from 'node:http' import { isLoopbackHostname } from './loopback-hostname.ts' -/** The request facts the fence reads (structural subset of IncomingMessage). */ +/** The request facts the fence reads from either HTTP representation. */ interface ApiTrustRequest { - headers: IncomingHttpHeaders + headers: IncomingHttpHeaders | Headers } -function header(headers: IncomingHttpHeaders, name: string): string | undefined { +function header(headers: IncomingHttpHeaders | Headers, name: string): string | undefined { + if (headers instanceof Headers) return headers.get(name) ?? undefined const value = headers[name] return typeof value === 'string' ? value : undefined } @@ -88,7 +89,7 @@ function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): bool /** * Decide whether one /api request may reach the RPC bridge. - * @param request - node HTTP request facts (headers). + * @param request - Node HTTP or Fetch request facts (headers). * @param trustedHosts - non-loopback authorities this deployment serves: exact `host:port`, or port-less `host` matching any port. * @returns true when the Host is ours (loopback or trusted) and any attached browser markers are same-origin. */ diff --git a/packages/client/connection/src/http-bridge.ts b/packages/client/connection/src/http-bridge.ts index 88d577bef8..cdf8d12bfe 100644 --- a/packages/client/connection/src/http-bridge.ts +++ b/packages/client/connection/src/http-bridge.ts @@ -5,7 +5,13 @@ import type { IncomingMessage, ServerResponse } from 'node:http' -interface FetchHandler { +/** Transport-independent request handler consumed by the Host HTTP bridge. */ +export interface FetchHandler { + /** + * Handle one standard Fetch request. + * @param request - request produced by the active transport bridge. + * @returns complete or streaming Fetch response. + */ fetch(request: Request): Promise } diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index d8b6ef8846..aefdcdadf4 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -12,6 +12,7 @@ import { rejectWebSocketUpgrade, WebSocketDownlinks } from './websocket-downlink export type { ConnectionRpcAuthority, + ConnectionRpcEndpointMatcher, ConnectionRpcHandler, ConnectionRpcHandlerOptions, HostConnectionHandle, @@ -24,7 +25,7 @@ export { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts' /** Stable Cordis plugin name. */ export const name = 'client-connection' -/** Services required before providing Connection; legacy `/api` attaches when apiProxy is present. */ +/** Services required before providing Connection; API Proxy is an optional `/api` fallback. */ export const inject = ['httpServer'] /** Plugin config: the deployment's non-loopback serving authorities. */ @@ -93,35 +94,44 @@ export function apply(ctx: Context, config?: ConnectionConfig): void { // Config boundary: a malformed entry fails the load loudly here rather than // silently authorizing its hostname prefix at request time. for (const entry of trustedHosts) assertTrustedAuthority(entry) - new HostConnectionService(ctx, trustedHosts) + const connection = new HostConnectionService(ctx, trustedHosts) + const fetchHandler = connection.createSharedFetchHandler(API_PATH, { + async fetch(request) { + const pathname = new URL(request.url).pathname + const method = pathname.startsWith(`${API_PATH}/`) + ? pathname.slice(API_PATH.length + 1) + : undefined + if (method !== undefined + && PRIVILEGED_METHODS.has(method) + && !isTrustedApiRequest(request, [])) { + return new Response('forbidden', { status: 403 }) + } + if (request.method === 'GET' && (pathname === MUX_EVENTS_PATH || pathname === HOST_EVENTS_PATH)) { + return new Response('upgrade required', { + status: 426, + headers: { connection: 'Upgrade', upgrade: 'websocket' }, + }) + } + const apiProxy = ctx.get('apiProxy') + if (apiProxy === undefined) return new Response('not found', { status: 404 }) + return toFetchHandler(apiProxy).fetch(request) + }, + }) + const route: WebRoute = { + kind: 'prefix', + path: API_PATH, + handler: async (req, res) => { + if (!isTrustedApiRequest(req, trustedHosts)) { + res.writeHead(403) + res.end('forbidden') + return + } + await bridge(req, res, fetchHandler) + }, + } + ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route') ctx.inject(['apiProxy'], (apiCtx) => { - const apiHandler = toFetchHandler(apiCtx.apiProxy) const downlinks = new WebSocketDownlinks(apiCtx.apiProxy) - const route: WebRoute = { - kind: 'prefix', - path: API_PATH, - handler: async (req, res) => { - const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname - const method = pathname.startsWith(`${API_PATH}/`) - ? pathname.slice(API_PATH.length + 1) - : undefined - const allowed = method !== undefined && PRIVILEGED_METHODS.has(method) - ? isTrustedApiRequest(req, []) - : isTrustedApiRequest(req, trustedHosts) - if (!allowed) { - res.writeHead(403) - res.end('forbidden') - return - } - if (req.method === 'GET' && (pathname === MUX_EVENTS_PATH || pathname === HOST_EVENTS_PATH)) { - res.writeHead(426, { connection: 'Upgrade', upgrade: 'websocket' }) - res.end('upgrade required') - return - } - await bridge(req, res, apiHandler) - }, - } - apiCtx.effect(() => apiCtx.httpServer.register(route), 'client-connection: /api route') const registerDownlink = ( path: string, handle: WebUpgradeRoute['handler'], diff --git a/packages/client/connection/src/rpc-host.ts b/packages/client/connection/src/rpc-host.ts index a6fbdb0264..7d3e5ff6f5 100644 --- a/packages/client/connection/src/rpc-host.ts +++ b/packages/client/connection/src/rpc-host.ts @@ -11,9 +11,11 @@ import { type RpcId as RpcIdType, type ServerResponse as RpcServerResponse, } from '@deepseek-ai/dsh-host-apiproxy/api' -import { bridge } from './http-bridge.ts' +import { bridge, type FetchHandler } from './http-bridge.ts' import { isTrustedApiRequest } from './api-request-trust.ts' +import { API_PATH } from './api-path.ts' import type { + ConnectionRpcEndpointMatcher, ConnectionRpcHandler, ConnectionRpcHandlerOptions, HostConnectionHandle, @@ -24,8 +26,23 @@ const INVALID_REQUEST_RPC_ID = RpcId('invalid-request') const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/ const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/ +interface ConnectionRpcInterceptor { + readonly matches: ConnectionRpcEndpointMatcher + readonly fetchHandler: FetchHandler + readonly options: ConnectionRpcHandlerOptions +} + +declare module 'cordis' { + interface Context { + /** Host Connection transport and RPC registrations. */ + connection: HostConnectionHandle + } +} + /** Host Connection service whose channel registrations belong to the caller fiber. */ export class HostConnectionService extends Service implements HostConnectionHandle { + private readonly interceptors = new Map() + /** * Provide the Host half over the active HTTP server. * @param ctx - owning Connection plugin context. @@ -40,6 +57,33 @@ export class HostConnectionService extends Service implements HostConnectionHand const owner = this.ctx return { handle: (channel, handler, options) => this.register(owner, channel, handler, options), + intercept: (channel, matches, handler, options) => + this.registerInterceptor(owner, channel, matches, handler, options), + } + } + + /** + * Compose one shared-channel Fetch handler from its interceptor and fallback. + * @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. + */ + createSharedFetchHandler( + channel: '/api', + fallback: FetchHandler, + ): FetchHandler { + return { + fetch: (request) => { + const endpoint = endpointFromPath(channel, new URL(request.url).pathname) + const interceptor = this.interceptors.get(channel) + if (endpoint === undefined || interceptor === undefined || !interceptor.matches(endpoint)) { + return fallback.fetch(request) + } + if (interceptor.options.authority === 'loopback' && !isTrustedApiRequest(request, [])) { + return Promise.resolve(new Response('forbidden', { status: 403 })) + } + return interceptor.fetchHandler.fetch(request) + }, } } @@ -69,12 +113,38 @@ export class HostConnectionService extends Service implements HostConnectionHand `client-connection: ${channel} rpc channel`, ) } + + private registerInterceptor( + owner: Context, + channel: string, + matches: ConnectionRpcEndpointMatcher, + handler: ConnectionRpcHandler, + options: ConnectionRpcHandlerOptions, + ): () => Promise { + if (channel !== API_PATH) { + throw new Error(`connection: invalid shared RPC channel ${JSON.stringify(channel)}`) + } + const interceptor: ConnectionRpcInterceptor = { + matches, + fetchHandler: rpcFetchHandler(channel, handler), + options, + } + return owner.effect(() => { + if (this.interceptors.has(channel)) { + throw new Error(`connection: shared RPC channel ${JSON.stringify(channel)} already has an interceptor`) + } + this.interceptors.set(channel, interceptor) + return () => { + this.interceptors.delete(channel) + } + }, `client-connection: ${channel} rpc interceptor`) + } } function rpcFetchHandler( channel: string, handler: ConnectionRpcHandler, -): { fetch(request: Request): Promise } { +): FetchHandler { return { async fetch(request: Request): Promise { const endpoint = endpointFromPath(channel, new URL(request.url).pathname) diff --git a/packages/client/connection/src/rpc.ts b/packages/client/connection/src/rpc.ts index ab68783724..e1260f00e8 100644 --- a/packages/client/connection/src/rpc.ts +++ b/packages/client/connection/src/rpc.ts @@ -18,11 +18,14 @@ export type ConnectionRpcHandler = ( signal: AbortSignal, ) => Promise> +/** Synchronous ownership test for one endpoint on a shared RPC channel. */ +export type ConnectionRpcEndpointMatcher = (endpoint: string) => boolean + /** Host registry for logical RPC channels carried by the current transport. */ export interface HostConnectionRpc { /** * Register one absolute channel prefix and its trust policy. - * @param channel - absolute logical channel such as `/api2`. + * @param channel - absolute logical channel such as `/rpc`. * @param handler - decoded endpoint handler returning the existing RPC result shape. * @param options - channel trust policy. * @returns asynchronous disposer removing the channel and its physical route. @@ -32,6 +35,21 @@ export interface HostConnectionRpc { handler: ConnectionRpcHandler, options: ConnectionRpcHandlerOptions, ): () => Promise + + /** + * Intercept owned endpoints on the shared `/api` channel before its fallback. + * @param channel - reserved shared channel; currently `/api`. + * @param matches - synchronous endpoint ownership test. + * @param handler - decoded endpoint handler returning the existing RPC result shape. + * @param options - trust policy for every endpoint claimed by this interceptor. + * @returns asynchronous disposer removing the interceptor. + */ + intercept( + channel: '/api', + matches: ConnectionRpcEndpointMatcher, + handler: ConnectionRpcHandler, + options: ConnectionRpcHandlerOptions, + ): () => Promise } /** Host `ctx.connection` shape consumed by transport-independent adapters. */ @@ -44,7 +62,7 @@ export interface HostConnectionHandle { export interface ClientConnectionRpc { /** * Call one endpoint through an already registered logical channel. - * @param channel - absolute logical channel such as `/api2`. + * @param channel - absolute logical channel such as `/api`. * @param endpoint - channel-relative endpoint such as `goals/create`. * @param payload - channel-owned request payload. * @param signal - optional caller cancellation. diff --git a/packages/client/connection/tests/client-apply.spec.ts b/packages/client/connection/tests/client-apply.spec.ts index 3ce8b89ecb..6bf9c26b46 100644 --- a/packages/client/connection/tests/client-apply.spec.ts +++ b/packages/client/connection/tests/client-apply.spec.ts @@ -204,7 +204,7 @@ describe('connection client apply', () => { expect(sockets[0]?.readyState).toBe(FakeWebSocket.CLOSED) }) - it('carries generic RPC calls over the isolated channel with rpcId echo validation', async () => { + it('carries RPC calls over the shared API channel with rpcId echo validation', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '' } const handle = await mount() const original = globalThis.fetch @@ -221,13 +221,13 @@ describe('connection client apply', () => { }) } try { - await expect(handle.rpc.call('/api2', 'goals/create', { args: { agentId: 'agent-1' } })) + await expect(handle.rpc.call('/api', 'goals/create', { args: { agentId: 'agent-1' } })) .resolves.toEqual({ ok: true, value: { ref: 'goal-1' } }) } finally { globalThis.fetch = original } expect(seen).toHaveLength(1) - expect(seen[0]?.url).toBe('http://dsh.internal/api2/goals/create') + expect(seen[0]?.url).toBe('http://dsh.internal/api/goals/create') expect(seen[0]?.body).toMatchObject({ type: 'client-request', method: 'goals/create', @@ -244,10 +244,10 @@ describe('connection client apply', () => { const abort = new AbortController() globalThis.fetch = vi.fn().mockResolvedValue(new Response('unavailable', { status: 503 })) try { - await expect(handle.rpc.call('/api2', 'goals/create', {}, abort.signal)) + await expect(handle.rpc.call('/api', 'goals/create', {}, abort.signal)) .rejects.toThrow('HTTP 503') expect(globalThis.fetch).toHaveBeenCalledWith( - new URL('https://harness.example/api2/goals/create'), + new URL('https://harness.example/api/goals/create'), expect.objectContaining({ signal: abort.signal }), ) @@ -257,9 +257,9 @@ describe('connection client apply', () => { rpcId: 'different-rpc', result: { ok: true, value: null }, })) - await expect(handle.rpc.call('/api2', 'goals/create', {})).rejects.toThrow('rpcId mismatch') + await expect(handle.rpc.call('/api', 'goals/create', {})).rejects.toThrow('rpcId mismatch') const fetch = vi.mocked(globalThis.fetch) - expect(fetch.mock.calls[0]?.[0]).toEqual(new URL('http://dsh.internal/api2/goals/create')) + expect(fetch.mock.calls[0]?.[0]).toEqual(new URL('http://dsh.internal/api/goals/create')) expect(fetch.mock.calls[0]?.[1]).not.toHaveProperty('signal') } finally { globalThis.fetch = original @@ -267,12 +267,12 @@ describe('connection client apply', () => { for (const [channel, endpoint] of [ ['api2', 'goals/create'], - ['/api2/path', 'goals/create'], - ['/api2', ''], - ['/api2', '.'], - ['/api2', '..'], - ['/api2', 'goals//create'], - ['/api2', 'goals/create?unsafe'], + ['/api/path', 'goals/create'], + ['/api', ''], + ['/api', '.'], + ['/api', '..'], + ['/api', 'goals//create'], + ['/api', 'goals/create?unsafe'], ] as const) { await expect(handle.rpc.call(channel, endpoint, {})).rejects.toThrow('invalid RPC target') } @@ -281,6 +281,6 @@ describe('connection client apply', () => { it('keeps generic Remote calls unavailable in the client-only fixture', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } const handle = await mount() - await expect(handle.rpc.call('/api2', 'goals/create', {})).rejects.toThrow(/unavailable in fixture mode/) + await expect(handle.rpc.call('/api', 'goals/create', {})).rejects.toThrow(/unavailable in fixture mode/) }) }) diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 1c42a9dc88..59ab8e6102 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -195,35 +195,36 @@ describe('connection node half', () => { await dispose() }) - it('provides a disposable generic RPC channel without requiring apiProxy', async () => { + it('provides a disposable dedicated RPC channel without requiring apiProxy', async () => { const ctx = new Context() const routes: WebRoute[] = [] ctx.provide('httpServer', fakeHttpServer(routes, []) as HttpServerService) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() - expect(routes).toHaveLength(0) + expect(routes).toHaveLength(1) + expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH }) const connection = ctx.get('connection') as HostConnectionHandle const calls: unknown[] = [] - const remove = connection.rpc.handle('/api2', async (endpoint, payload) => { + const remove = connection.rpc.handle('/rpc', async (endpoint, payload) => { calls.push({ endpoint, payload }) return { ok: true, value: { accepted: true } } }, { authority: 'trusted-host' }) - const route = routes.find(candidate => candidate.path === '/api2') + const route = routes.find(candidate => candidate.path === '/rpc') expect(route).toBeDefined() const request: ClientRequest = { type: 'client-request', - rpcId: RpcId('rpc-api2'), + rpcId: RpcId('rpc-dedicated'), method: 'goals/create', payload: { args: { agentId: 'agent-1' } }, } const result = fakeResponse() - await route!.handler(fakePost({ host: '127.0.0.1:3080' }, '/api2/goals/create', request), result.response) + await route!.handler(fakePost({ host: '127.0.0.1:3080' }, '/rpc/goals/create', request), result.response) expect(result.state.status).toBe(200) expect(JSON.parse(String(result.state.body))).toEqual({ type: 'server-response', - rpcId: 'rpc-api2', + rpcId: 'rpc-dedicated', result: { ok: true, value: { accepted: true } }, }) expect(calls).toEqual([{ @@ -231,11 +232,90 @@ describe('connection node half', () => { payload: { args: { agentId: 'agent-1' } }, }]) - expect(() => connection.rpc.handle('/api2', async () => ({ ok: true, value: null }), { + expect(() => connection.rpc.handle('/rpc', async () => ({ ok: true, value: null }), { authority: 'trusted-host', })).toThrow(/duplicate route/) await remove() + expect(routes.map(candidate => candidate.path)).toEqual([API_PATH]) + await fiber.dispose() expect(routes).toHaveLength(0) + }) + + it('dispatches claimed /api endpoints before the API Proxy fallback and withdraws the claim', async () => { + const ctx = new Context() + const routes: WebRoute[] = [] + ctx.provide('httpServer', fakeHttpServer(routes, []) as HttpServerService) + 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 + const calls: unknown[] = [] + const remove = connection.rpc.intercept( + '/api', + endpoint => endpoint === 'goals/create', + async (endpoint, payload) => { + calls.push({ endpoint, payload }) + return { ok: true, value: { accepted: true } } + }, + { authority: 'trusted-host' }, + ) + expect(() => connection.rpc.intercept( + '/api', + () => true, + async () => ({ ok: true, value: null }), + { authority: 'trusted-host' }, + )).toThrow('already has an interceptor') + expect(() => connection.rpc.intercept( + '/rpc' as '/api', + () => true, + async () => ({ ok: true, value: null }), + { authority: 'trusted-host' }, + )).toThrow('invalid shared RPC channel') + const route = routes.find(candidate => candidate.path === API_PATH)! + const request: ClientRequest = { + type: 'client-request', + rpcId: RpcId('rpc-shared'), + method: 'goals/create', + payload: { args: { agentId: 'agent-1' } }, + } + + const claimed = fakeResponse() + await route.handler(fakePost({ host: '127.0.0.1:3080' }, '/api/goals/create', request), claimed.response) + expect(JSON.parse(String(claimed.state.body))).toEqual({ + type: 'server-response', + rpcId: 'rpc-shared', + result: { ok: true, value: { accepted: true } }, + }) + expect(calls).toEqual([{ + endpoint: 'goals/create', + payload: { args: { agentId: 'agent-1' } }, + }]) + + const denied = fakeResponse() + await route.handler(fakePost({ host: 'other.example' }, '/api/goals/create', request), denied.response) + expect(denied.state).toMatchObject({ status: 403, body: 'forbidden' }) + expect(calls).toHaveLength(1) + + const unclaimed = fakeResponse() + await route.handler(fakeRequest({ host: '127.0.0.1:3080' }, '/api/session.list'), unclaimed.response) + expect(unclaimed.state.status).toBe(404) + + await remove() + const withdrawn = fakeResponse() + await route.handler(fakePost({ host: '127.0.0.1:3080' }, '/api/goals/create', request), withdrawn.response) + expect(withdrawn.state.status).toBe(404) + expect(calls).toHaveLength(1) + + const removeLoopback = connection.rpc.intercept( + '/api', + endpoint => endpoint === 'goals/create', + async () => ({ ok: true, value: null }), + { authority: 'loopback' }, + ) + const loopbackOnly = fakeResponse() + await route.handler(fakePost({ host: 'harness.example' }, '/api/goals/create', request), loopbackOnly.response) + expect(loopbackOnly.state.status).toBe(403) + await removeLoopback() await fiber.dispose() }) @@ -246,20 +326,20 @@ describe('connection node half', () => { const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.example'] }) await fiber.await() const connection = ctx.get('connection') as HostConnectionHandle - const remove = connection.rpc.handle('/api2', async (endpoint) => { + const remove = connection.rpc.handle('/rpc', async (endpoint) => { if (endpoint === 'fail') throw new Error('handler broke') return { ok: true, value: null } }, { authority: 'trusted-host', }) - const route = routes[0]! + const route = routes.find(candidate => candidate.path === '/rpc')! const denied = fakeResponse() - await route.handler(fakePost({ host: 'other.example' }, '/api2/goals/create', {}), denied.response) + await route.handler(fakePost({ host: 'other.example' }, '/rpc/goals/create', {}), denied.response) expect(denied.state).toMatchObject({ status: 403, body: 'forbidden' }) const methodMismatch = fakeResponse() - await route.handler(fakePost({ host: 'harness.example' }, '/api2/goals/create', { + await route.handler(fakePost({ host: 'harness.example' }, '/rpc/goals/create', { type: 'client-request', rpcId: 'rpc-bad', method: 'other', payload: {}, }), methodMismatch.response) expect(JSON.parse(String(methodMismatch.state.body))).toMatchObject({ @@ -268,12 +348,12 @@ describe('connection node half', () => { }) for (const [request, status] of [ - [fakeRequest({ host: 'harness.example' }, '/api2/goals/create'), 404], + [fakeRequest({ host: 'harness.example' }, '/rpc/goals/create'), 404], [fakePost({ host: 'harness.example' }, '/outside/goals/create', {}), 404], - [fakePost({ host: 'harness.example' }, '/api2/goals//create', {}), 404], - [fakeRawPost({ host: 'harness.example' }, '/api2/goals/create', '{}'), 415], - [fakeRawPost({ host: 'harness.example', 'content-type': 'text/plain' }, '/api2/goals/create', '{}'), 415], - [fakeRawPost({ host: 'harness.example', 'content-type': 'application/json; charset=utf-8' }, '/api2/goals/create', '{'), 400], + [fakePost({ host: 'harness.example' }, '/rpc/goals//create', {}), 404], + [fakeRawPost({ host: 'harness.example' }, '/rpc/goals/create', '{}'), 415], + [fakeRawPost({ host: 'harness.example', 'content-type': 'text/plain' }, '/rpc/goals/create', '{}'), 415], + [fakeRawPost({ host: 'harness.example', 'content-type': 'application/json; charset=utf-8' }, '/rpc/goals/create', '{'), 400], ] as const) { const response = fakeResponse() await route.handler(request, response.response) @@ -286,7 +366,7 @@ describe('connection node half', () => { [null, 'invalid-request'], ] as const) { const response = fakeResponse() - await route.handler(fakePost({ host: 'harness.example' }, '/api2/goals/create', body), response.response) + await route.handler(fakePost({ host: 'harness.example' }, '/rpc/goals/create', body), response.response) expect(JSON.parse(String(response.state.body))).toMatchObject({ rpcId, result: { ok: false, error: { code: 'bad-request' } }, @@ -294,7 +374,7 @@ describe('connection node half', () => { } const failed = fakeResponse() - await route.handler(fakePost({ host: 'harness.example' }, '/api2/fail', { + await route.handler(fakePost({ host: 'harness.example' }, '/rpc/fail', { type: 'client-request', rpcId: 'rpc-fail', method: 'fail', payload: {}, }), failed.response) expect(failed.state).toMatchObject({ status: 500, body: 'handler failure: Error: handler broke' }) diff --git a/packages/host/api-gateway/README.i18n.yaml b/packages/host/api-gateway/README.i18n.yaml index 2abe47e0d3..747aa65665 100644 --- a/packages/host/api-gateway/README.i18n.yaml +++ b/packages/host/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/host/api-gateway/README.md -README.md: 3ef926ace2ee4d6008b1d6c18b1e070fa39bc176 -README.zh.md: 77b8b8a87d5f511000aac5cf9f75ebca5fcdfbca +README.md: cc80bb19fec15414aa0857154a8a36fb4f642672 +README.zh.md: 6febb1cfe4fc7fa4c5a17e1e4f6a21e2ee03e295 diff --git a/packages/host/api-gateway/README.md b/packages/host/api-gateway/README.md index 3ef926ace2..cc80bb19fe 100644 --- a/packages/host/api-gateway/README.md +++ b/packages/host/api-gateway/README.md @@ -10,13 +10,13 @@ Two-sided Remote control for Host and Client Cordis environments. The Host entry Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use registered `ctx.typert.lookups` providers, while `@RemoteContext` resolves its receiver through a registered Host Context provider. 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 the trusted-host `/api2` unary RPC channel when Connection is available. Direct `invoke()` calls preserve business errors; `TypertGatewayError` distinguishes failures owned by dispatch, binding, providers, lookup, Context, arguments, and codecs. +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. ## Client service: `ClientApi` (ctx key: `api`) `ctx.api.mount()` validates and registers a generated Host-for-Client contribution, then installs concrete direct and scoped methods for the calling Cordis fiber. Duplicate endpoints, namespace collisions, and descriptors without strict generated codecs fail before methods become callable. -Each call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api2', endpoint, ...)`. The returned value is validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls, and makes retained method handles reject. +Each call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api', endpoint, ...)`. The returned value is validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls, and makes retained method handles reject. Generated declaration merges provide the TypeScript API. 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/host/api-gateway/README.zh.md b/packages/host/api-gateway/README.zh.md index 77b8b8a87d..6febb1cfe4 100644 --- a/packages/host/api-gateway/README.zh.md +++ b/packages/host/api-gateway/README.zh.md @@ -10,13 +10,13 @@ 严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用已向 `ctx.typert.lookups` 注册的提供方,`@RemoteContext` 则通过已注册的 Host Context 提供方解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。 -Connection 可用时,Host 入口会注册 trusted-host 的 `/api2` 一元 RPC 通道。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 可区分分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。 +Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领 endpoint 则交给 API Proxy。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 可区分分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。 ## Client 服务:`ClientApi`(ctx key:`api`) `ctx.api.mount()` 会校验并注册生成的 Host-for-Client 贡献项,然后为发起调用的 Cordis fiber 安装具体的直接方法和作用域方法。重复端点、命名空间冲突,以及缺少生成的严格编解码器的描述符,都会在方法可调用前报错。 -每次调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api2', endpoint, ...)` 发送。返回值经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用,并使外部仍持有的方法句柄在调用时返回拒绝。 +每次调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。返回值经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用,并使外部仍持有的方法句柄在调用时返回拒绝。 生成的声明合并提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。 diff --git a/packages/host/api-gateway/src/client/index.ts b/packages/host/api-gateway/src/client/index.ts index fe8fd9f1b3..1f92bc0748 100644 --- a/packages/host/api-gateway/src/client/index.ts +++ b/packages/host/api-gateway/src/client/index.ts @@ -248,7 +248,7 @@ class ClientApiService extends Service implements ClientApi { }) const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined if (connection === undefined) throw new Error(`client api: ${endpoint} has no active Connection`) - const result = await connection.rpc.call('/api2', endpoint, { args }, token.abort.signal) + const result = await connection.rpc.call('/api', endpoint, { args }, token.abort.signal) if (!mountActive(token)) throw new Error(`client api: Remote method ${endpoint} was withdrawn during invocation`) if (!result.ok) throw remoteFailure(endpoint, result.error) return parse(descriptor.result, result.value, endpoint, 'result') diff --git a/packages/host/api-gateway/src/index.ts b/packages/host/api-gateway/src/index.ts index c83772261a..2adfaa8387 100644 --- a/packages/host/api-gateway/src/index.ts +++ b/packages/host/api-gateway/src/index.ts @@ -5,6 +5,7 @@ */ import { Context, Service, symbols } from 'cordis' +import type { ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection' import { remoteMethods, type InvocationDescriptor, @@ -35,26 +36,7 @@ interface ResolvedBinding { readonly original: object } -type ConnectionRpcResult = - | { readonly ok: true; readonly value: unknown } - | { - readonly ok: false - readonly error: { - readonly code: 'internal' - readonly message: string - readonly details: Record - } - } - -interface HostConnectionLike { - readonly rpc: { - handle( - channel: string, - handler: (endpoint: string, payload: unknown, signal: AbortSignal) => Promise, - options: { readonly authority: 'trusted-host' | 'loopback' }, - ): () => Promise - } -} +type ConnectionRpcResult = Awaited> /** Dispatch failure produced outside the invoked business method. */ export class TypertGatewayError extends Error { @@ -101,15 +83,32 @@ export class TypertGatewayService extends Service implements TypertGateway { constructor(ctx: Context) { super(ctx, 'typertGateway') ctx.inject(['connection'], (connectionCtx) => { - const connection = connectionCtx.get('connection') as unknown as HostConnectionLike - connection.rpc.handle( - '/api2', + connectionCtx.connection.rpc.intercept( + '/api', + endpoint => this.claimsEndpoint(endpoint), (endpoint, payload, signal) => this.dispatchRpc(endpoint, payload, signal), { authority: 'trusted-host' }, ) }) } + private claimsEndpoint(endpoint: string): boolean { + const segments = endpoint.split('/') + if (segments.length !== 2 || segments[0] === '' || segments[1] === '') return false + const [namespace, method] = segments as [string, string] + if (this.ctx.typert.local.get(endpoint) !== undefined || this.ctx.typert.local.hasSeen(endpoint)) return true + for (const [serviceKey, definition] of Object.entries(this.ctx.reflect.props)) { + if (definition.type !== 'service') continue + const receiver = this.ctx.get(serviceKey) as unknown + if (!isObject(receiver)) continue + const original = originalOf(receiver) + const binding = Reflect.get(original, 'typertGateway') as unknown + if (!isObject(binding) || Reflect.get(binding, 'namespace') !== namespace) continue + if (remoteMethods(original).some(candidate => (candidate.exportName ?? candidate.method) === method)) return true + } + return false + } + /** * Invoke one live Remote method through strict generated reflection or SRC markers. * @param request - decoded endpoint and exact named wire arguments. diff --git a/packages/host/api-gateway/tests/client.spec.ts b/packages/host/api-gateway/tests/client.spec.ts index 8c0753f3f9..ab08ef09bc 100644 --- a/packages/host/api-gateway/tests/client.spec.ts +++ b/packages/host/api-gateway/tests/client.spec.ts @@ -109,7 +109,7 @@ describe('Client TypeRT API', () => { await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).resolves.toEqual({ ref: 'goal-1' }) expect(call).toHaveBeenCalledWith( - '/api2', + '/api', 'goals/create', { args: { agentId: 'agent-1', request: { objective: 'ship' } } }, expect.any(AbortSignal), @@ -144,7 +144,7 @@ describe('Client TypeRT API', () => { await expect(agentCtx.goals.create({ objective: 'ship scoped' })).resolves.toEqual({ ref: 'goal-2' }) expect(call).toHaveBeenCalledWith( - '/api2', + '/api', 'goals/create', { args: { agentId: 'agent-2', request: { objective: 'ship scoped' } } }, expect.any(AbortSignal), @@ -175,7 +175,7 @@ describe('Client TypeRT API', () => { await expect(agentCtx.goals.rename({ objective: 'land' })).resolves.toEqual({ renamed: true }) expect(call).toHaveBeenCalledWith( - '/api2', + '/api', 'goals/rename', { args: { agentId: 'agent-2', request: { objective: 'land' } } }, expect.any(AbortSignal), diff --git a/packages/host/api-gateway/tests/gateway.spec.ts b/packages/host/api-gateway/tests/gateway.spec.ts index 0b550e126d..d5a3f9a8ee 100644 --- a/packages/host/api-gateway/tests/gateway.spec.ts +++ b/packages/host/api-gateway/tests/gateway.spec.ts @@ -96,6 +96,7 @@ type FakeRpcHandler = (endpoint: string, payload: unknown, signal: AbortSignal) class FakeConnectionService extends Service { channel: string | undefined authority: string | undefined + matches: ((endpoint: string) => boolean) | undefined handler: FakeRpcHandler | undefined constructor(ctx: Context) { @@ -105,14 +106,21 @@ class FakeConnectionService extends Service { get rpc() { const owner = this.ctx return { - handle: (channel: string, handler: FakeRpcHandler, options: { readonly authority: string }) => + intercept: ( + channel: string, + matches: (endpoint: string) => boolean, + handler: FakeRpcHandler, + options: { readonly authority: string }, + ) => owner.effect(() => { this.channel = channel this.authority = options.authority + this.matches = matches this.handler = handler return () => { this.channel = undefined this.authority = undefined + this.matches = undefined this.handler = undefined } }), @@ -820,7 +828,7 @@ describe('TypertGatewayService', () => { }), 'invocation-unavailable') }) - it('mounts /api2 through an optional Connection and returns existing RPC results', async () => { + it('mounts a shared /api interceptor through an optional Connection and returns existing RPC results', async () => { const ctx = new Context().extend({ fixtureScope: 'rpc-caller' }) await ctx.plugin(TypertRegistry) await ctx.plugin(FakeConnectionService) @@ -828,13 +836,18 @@ describe('TypertGatewayService', () => { await gatewayFiber await ctx.plugin(GoalService) const connection = rawConnection(ctx) - expect(connection).toMatchObject({ channel: '/api2', authority: 'trusted-host' }) + expect(connection).toMatchObject({ channel: '/api', authority: 'trusted-host' }) registerAgentLookup(ctx, { id: 'agent-1' }) registerStrict(ctx, [createDescriptor()]) + expect(connection.matches?.('goals/create')).toBe(true) + expect(connection.matches?.('goals/passthrough')).toBe(true) + expect(connection.matches?.('goals')).toBe(false) + expect(connection.matches?.('goals/missing')).toBe(false) + expect(connection.matches?.('legacy/list')).toBe(false) const signal = new AbortController().signal const handler = connection.handler - if (handler === undefined) throw new Error('fixture Connection did not retain the /api2 handler') + if (handler === undefined) throw new Error('fixture Connection did not retain the /api interceptor') await expect(handler('goals/create', { args: { agentId: 'agent-1', request: { title: 'ship' } }, }, signal)).resolves.toEqual({ @@ -873,7 +886,7 @@ describe('TypertGatewayService', () => { expect(connection.handler).toBeUndefined() }) - it('dispatches a generated invocation through the real /api2 HTTP carrier', async () => { + it('dispatches claimed invocations through /api and leaves unclaimed endpoints to its fallback', async () => { const ctx = new Context().extend({ fixtureScope: 'http-caller' }) const routes: WebRoute[] = [] ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService) @@ -886,11 +899,12 @@ describe('TypertGatewayService', () => { await goalFiber const removeLookup = registerAgentLookup(ctx, { id: 'agent-1' }) const removeStrict = registerStrict(ctx, [createDescriptor()]) + let strictActive = true expect(routes).toHaveLength(1) const server = await serveRoute(routes[0]!) try { - const response = await fetch(`${server.origin}/api2/goals/create`, { + const response = await fetch(`${server.origin}/api/goals/create`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ @@ -909,9 +923,54 @@ describe('TypertGatewayService', () => { value: { agentId: 'agent-1', title: 'ship', scope: 'http-caller' }, }, }) + + const invalid = await fetch(`${server.origin}/api/goals/create`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', + rpcId: 'rpc-invalid', + method: 'goals/create', + payload: { invalid: true }, + }), + }) + expect(invalid.status).toBe(200) + await expect(invalid.json()).resolves.toMatchObject({ + type: 'server-response', + rpcId: 'rpc-invalid', + result: { + ok: false, + error: { code: 'internal', message: expect.stringContaining('plain-object args field') }, + }, + }) + + await removeStrict() + strictActive = false + const withdrawn = await fetch(`${server.origin}/api/goals/create`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', + rpcId: 'rpc-withdrawn', + method: 'goals/create', + payload: { args: { agentId: 'agent-1', request: { title: 'ship' } } }, + }), + }) + expect(withdrawn.status).toBe(200) + await expect(withdrawn.json()).resolves.toMatchObject({ + type: 'server-response', + rpcId: 'rpc-withdrawn', + result: { + ok: false, + error: { code: 'internal', message: expect.stringContaining('strict definition was withdrawn') }, + }, + }) + + const unclaimed = await fetch(`${server.origin}/api/legacy/list`, { method: 'POST' }) + expect(unclaimed.status).toBe(404) } finally { await server.close() - await removeStrict() + if (strictActive) await removeStrict() await removeLookup() await goalFiber.dispose() await gatewayFiber.dispose() From cd566f26f56ae8ac4a56c23adec94ee067def193 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:46:02 +0800 Subject: [PATCH 065/104] test(client-remotes): cover shared API bundle chain --- packages/client/remotes/tests/built-lib.e2e.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/client/remotes/tests/built-lib.e2e.ts b/packages/client/remotes/tests/built-lib.e2e.ts index bbba218844..bef3f4ad65 100644 --- a/packages/client/remotes/tests/built-lib.e2e.ts +++ b/packages/client/remotes/tests/built-lib.e2e.ts @@ -6,7 +6,7 @@ import { describe, expect, it } from 'vitest' /** * Built-artifact smoke for the first generated Remote: plain Node boots the - * Host and Browser bundle handoffs, then crosses the real `/api2` HTTP route. + * Host and Browser bundle handoffs, then crosses the shared `/api` HTTP route. */ const packageDir = fileURLToPath(new URL('..', import.meta.url)) @@ -98,7 +98,9 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { host.agents.register(rootAgent) host.agents.register(scopedAgent) - if (routes.length !== 1) throw new Error('Gateway did not register exactly one /api2 route') + if (routes.length !== 1 || routes[0].path !== '/api') { + throw new Error('Connection did not register exactly one /api route') + } const server = createServer((request, response) => { void routes[0].handler(request, response) }) await new Promise(resolveListen => server.listen(0, '127.0.0.1', resolveListen)) const address = server.address() From 88385a658e7e1e138e002a9146919abe428633b0 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:06:54 +0800 Subject: [PATCH 066/104] docs(cordis): refresh gateway service location --- docs/cordis-catalog/services.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 9f5e66ea36..a0fee79546 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2650,7 +2650,7 @@ Resolve strict generated definitions or conservative SRC markers against current async invoke(request: InvokeRemoteRequest): Promise ``` -Source: [`packages/host/api-gateway/src/index.ts:94`](../../packages/host/api-gateway/src/index.ts) +Source: [`packages/host/api-gateway/src/index.ts:76`](../../packages/host/api-gateway/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` From 9b63d72c9482c1dfd39f79ec1fd3b0562b521b93 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:49:35 +0800 Subject: [PATCH 067/104] fix(typert): harden remote reflection boundaries --- ...08-02-typert-remote-method-calls.i18n.yaml | 6 + .../2026-08-02-typert-remote-method-calls.md | 66 +++--- ...026-08-02-typert-remote-method-calls.zh.md | 66 +++--- ...08-02-typert-remote-method-calls.i18n.yaml | 6 - docs/config-catalog.md | 2 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/core.i18n.yaml | 4 +- docs/core-data-structures/core.md | 1 + docs/core-data-structures/core.zh.md | 1 + docs/core-data-structures/typert.i18n.yaml | 6 + docs/core-data-structures/typert.md | 196 ++++++++++++++++++ docs/core-data-structures/typert.zh.md | 196 ++++++++++++++++++ package.json | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/host/api-gateway/src/client/index.ts | 1 + packages/host/api-gateway/src/index.ts | 12 +- .../host/api-gateway/tests/client.spec.ts | 15 +- .../host/api-gateway/tests/gateway.spec.ts | 29 +++ packages/typert/generator/src/analyzer.ts | 104 +++++++++- .../generator/tests/remote-model.spec.ts | 26 +++ packages/typert/loader/src/index.ts | 6 +- packages/typert/loader/tests/loader.spec.ts | 52 +++-- packages/typert/registry/src/service.ts | 26 ++- packages/typert/registry/src/types.ts | 7 +- packages/typert/registry/tests/typert.spec.ts | 18 ++ packages/typert/type-meta/src/index.ts | 1 + packages/typert/type-meta/src/types.ts | 16 ++ scripts/type-equiv.manifest.json | 60 ++++++ 28 files changed, 813 insertions(+), 116 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml rename .agents/notes/{proposed => implemented}/architecture/2026-08-02-typert-remote-method-calls.md (85%) rename .agents/notes/{proposed => implemented}/architecture/2026-08-02-typert-remote-method-calls.zh.md (85%) delete mode 100644 .agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml create mode 100644 docs/core-data-structures/typert.i18n.yaml create mode 100644 docs/core-data-structures/typert.md create mode 100644 docs/core-data-structures/typert.zh.md diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml new file mode 100644 index 0000000000..752a5d4c8b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.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-02-typert-remote-method-calls.md +2026-08-02-typert-remote-method-calls.md: 91ab8e44ff8aedf666fe3426b85b54491deb340c +2026-08-02-typert-remote-method-calls.zh.md: 73abd53109d871076aa41af39825c80c35ac3f26 diff --git a/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md similarity index 85% rename from .agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.md rename to .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index 61c8f61468..91ab8e44ff 100644 --- a/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -1,6 +1,6 @@ # Agent Note: TypeRT Gateway Targeted Method Calls -Status: proposed +Status: implemented English | [中文](2026-08-02-typert-remote-method-calls.zh.md) @@ -8,13 +8,13 @@ English | [中文](2026-08-02-typert-remote-method-calls.zh.md) The Host API Proxy handles direct method calls, stateful interactions, and Session event streams. These concerns have different lifecycles, routing semantics, and client programming interfaces. Continuing to export all business operations through one package would couple business Services, transport protocols, state machines, and client types. -This proposal addresses only targeted method calls in which one request produces one result. Stateful interactions such as Permission and Approval, as well as Session event streams, do not use this design and will be designed separately. +This decision covers only targeted method calls in which one request produces one result. Stateful interactions such as Permission and Approval, as well as Session event streams, remain separate designs. -The contract for a direct method call belongs to the business Service that implements it. Business developers should declare only which methods are remotely callable, without also maintaining a central API interface, routing table, parameter conversion table, client stub, and Zod schema. +The contract for a direct method call belongs to the business Service that implements it. Business developers declare only which methods are remotely callable, without also maintaining a central API interface, routing table, parameter conversion table, client stub, and Zod schema. The Host and Browser Client use separate TypeScript Programs because each side augments the Cordis `Context` type differently. A Remote projection must not import the complete Host declarations into a consumer or depend on Browser-specific types. If the TUI later reuses this programming interface, it must likewise see only methods marked Remote. TUI integration is outside the current scope, but the implementation boundary must preserve this isomorphic reuse. -## Proposal +## Decision A business Service declares callable methods with `@Remote` or `@RemoteContext()` and explicitly joins the Gateway through `bindTypeRTGateway()`. TypeRT generates the Host-local reflection artifact and a platform-independent Remote consumer projection from the Host Program. The Client Program continues to generate its own local reflection artifact independently. @@ -24,7 +24,7 @@ The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. T ## Components and Cordis services -| Component | Cordis service | Responsibility in this proposal | +| Component | Cordis service | Responsibility | |---|---|---| | `@deepseek-ai/dsh-type-meta` | Declares only the minimal `ctx.typert` protocol | Decorators, bindings, descriptors, lookup/Context, and the Remote map; no dependency on the compiler, Zod, Connection, or Browser | | TypeRT registry | `ctx.typert` | Separately stores reflection for the current environment, imported Remote contributions, lookup providers, and Context providers | @@ -104,7 +104,7 @@ ctx.typert.lookups.register('agent', { The static declaration tells TypeRT that `Agent` corresponds to `SessionId` on the wire. The runtime provider resolves an `agentId` in a request to the currently live `Agent` object. If either side is missing, the LIB build or the earliest resolvable runtime registration fails immediately. -Lookup objects such as Agent and Session may each occupy only one top-level parameter position. An ordinary JSON request may be passed as another complete parameter, but this proposal does not support `request.agent`, object destructuring, arrays of objects, nested lookups, or searching arbitrary complex structures for IDs. +Lookup objects such as Agent and Session may each occupy only one top-level parameter position. An ordinary JSON request may be passed as another complete parameter, but this design does not support `request.agent`, object destructuring, arrays of objects, nested lookups, or searching arbitrary complex structures for IDs. Remote Context uses a separate merge-extensible map and provider. The Agent package registers an `agent` Context provider that locates the Agent Context from its wire identity and resolves the Service key named by the descriptor from that Context. The Gateway does not know the internal structure of an Agent Context. @@ -150,7 +150,9 @@ ctx.typert.lookups wire ID 到 Host 活对象的 provider ctx.typert.contexts Host Context resolver 与 Client Context binder ``` -Every registration returns a disposer owned by the caller's Cordis fiber. The Gateway and API Service read the current snapshot before subscribing to changes, so business Services, generated contributions, providers, and consumers can load in any order. When any dependency is disposed, its related endpoints or methods become unavailable immediately. +Every registration returns a disposer owned by the caller's Cordis fiber. Client contribution mounting registers the descriptor set and concrete methods as one owned operation. The Host Gateway resolves descriptors, Services, and providers from current state for every claim and invocation instead of retaining endpoint registrations. Removing a strict definition, Service, or provider therefore makes the corresponding call unavailable without leaving a stale live object. + +The lookup registry retains the stable wire declaration after its live resolver unloads. SRC parsing continues to classify the parameter as a lookup, while invocation fails with `lookup-unavailable`; it never reclassifies the incoming ID as an ordinary JSON business object. Re-registering the same key with different parameter, wire, or canonical type symbols fails for the lifetime of that TypeRT Service. The registry's Host root entry has the complete `TypeRTService` interface merge. The registry implementation shared by Host and Client lives in a separate module without environment declarations. The registry's `/client` entry imports only that shared implementation and does not pass through the Host root entry, so it cannot bring Host Cordis declarations into the Client Program. @@ -312,11 +314,11 @@ agent.goals.create(request) The Root `Context` does not merge the scoped `goals` type; only `AgentContext` gains that property through `RemoteContextApi<'agent'>`. If a caller bypasses the type system and dynamically calls a scoped method from Root, the binder reports an explicit error. If the Client already has a Cordis service with the same name, or two contributions claim the same namespace and method incompatibly, mounting fails instead of overwriting the existing service. -Generated Remote JS contains only descriptors, symbol keys, and codecs; it does not bundle Host Service implementations. The API Service can create real functions from that data, so this proposal does not depend on a JavaScript Proxy. A Proxy remains an implementation option but is not a source of types or reflection. +Generated Remote JS contains only descriptors, symbol keys, and codecs; it does not bundle Host Service implementations. The API Service creates real functions from that data, so the runtime does not depend on a JavaScript Proxy. A Proxy remains an implementation option but is not a source of types or reflection. ## Cross-environment isomorphism constraints -Remote API is a consumer capability, not a synonym for Browser API. This phase implements only Browser Client contribution mounting, Connection RPC calls, and Agent Scope association. +Remote API is a consumer capability, not a synonym for Browser API. The shipped runtime implements Browser Client contribution mounting, Connection RPC calls, and Agent Scope association. Remote DTS, Remote JS, `RemoteApi`, `InvocationDescriptor`, the Remote RPC data protocol, and Context binders must not depend on the DOM, Browser module loaders, or HTTP. Through Connection, the Browser Client encodes descriptor-materialized methods as `/api` RPC calls. @@ -324,7 +326,7 @@ A future TUI can join the same call abstraction without changing business decora TUI runtime mounting, carriers, Agent Scope association, and SRC startup wiring are outside this phase. -The Web already depends on build artifacts such as `lib/client.js`, so it requires a complete `build:lib` before startup. After the Host Remote contract changes, developers must rebuild the lib and then start or restart the Web. The first phase does not implement incremental watching of the Remote contract. +The Web already depends on build artifacts such as `lib/client.js`, so it requires a complete `build:lib` before startup. After the Host Remote contract changes, developers rebuild the lib and then start or restart the Web. Incremental watching of the Remote contract is not implemented. ## SRC and LIB operating modes @@ -340,11 +342,11 @@ At runtime, LIB only loads definitions from `lib`; it does not start the TypeScr CI and releases use LIB. Moving all repository coverage to LIB is separate follow-up work and does not block this direct-method-call implementation. -## Host Gateway registration +## Host Gateway resolution -The Host Gateway observes both TypeRT Remote definitions and the Cordis Service lifecycle. When a Service carrying the `typertGateway` facet and a definition with the same service key are both available, the Gateway registers the definition's endpoints. Their arrival order does not matter. +The Host Gateway registers one `/api` interceptor with Connection and does not maintain a second endpoint registry. Its ownership matcher resolves each endpoint from the current TypeRT local registry or scans current Cordis Services for a matching `typertGateway` binding and SRC Remote marker. TypeRT definitions and business Services may therefore arrive in either order. -At startup, the Gateway reads the current snapshots of TypeRT definitions and the Cordis reflection store before subscribing to registry changes and `internal/service`. It reconciles definitions, live Services, and bindings by service key, and unregisters endpoints when a Service is replaced or disposed. If a definition, lookup provider, or Context provider is removed, dependent endpoints immediately become unavailable; the Gateway neither retains invalid objects nor degrades to invoking methods with raw IDs. +Invocation resolves the descriptor, receiver, lookup providers, and Context provider again from current state. A current strict descriptor takes precedence over SRC. After a strict endpoint has appeared, `TypeRTLocalRegistry.hasSeen()` keeps it owned when that descriptor is withdrawn and forbids SRC fallback for the remainder of the registry lifetime; re-registering the strict descriptor restores calls. Removing a Service or provider makes invocation fail explicitly, and the Gateway neither retains invalid objects nor invokes a method with a raw lookup ID. An ordinary `@Remote` call retains the original Service instance as receiver. After lookups succeed, the Gateway calls the member identified by `implementation ?? method` with parameters in descriptor order. @@ -417,7 +419,7 @@ ctx.api.goals.create(sessionId, request) → Client result codec 验证并返回 CreateGoalResult ``` -Remote does not define a second-layer `{ ok, value/error }` response. Successful values and Gateway errors use the existing RPC response's `result` directly. The Gateway adapter maps endpoint, schema, lookup, Context, Service, and business-invocation failures to `RpcError`; Connection transports that error. +Remote does not define a second-layer `{ ok, value/error }` response. Successful values and Gateway errors use the existing RPC response's `result` directly. The current adapter converts every Gateway and business-invocation failure to the existing `RpcError` envelope with `code: 'internal'`; the Gateway's structured error category remains available only in-process, while the message carries the diagnostic across Connection. The Gateway does not handle per-method permissions, caller identity, cancellation, idempotency, or long-lived connection state. TypeRT endpoints use Connection's trusted-host policy; unclaimed endpoints retain the legacy API Proxy's trust and privileged-method policies. Connection's WebSocket migration remains separate follow-up work. @@ -438,11 +440,11 @@ The Gateway registers only its ownership matcher and RPC handler with Connection - Business-object packages such as Agent/Session: own lookup, Context providers, canonical ID types, and public type-only entries. - Business Service packages: declare bindings, Remote methods, and their request/result types, and export the generated `/remote` subpath. -## Initial implementation scope +## Shipped scope and deferred work -The first vertical path implements `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()` and proves that the same direct descriptor with an Agent lookup supports both `ctx.api.goals.create(agentId, request)` and `agentCtx.goals.create(request)`. The scoped-receiver semantics of `@RemoteContext('agent')` remain a separate mode. +The shipped vertical path is `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`. The same direct descriptor with an Agent lookup supports both `ctx.api.goals.create(agentId, request)` and `agentCtx.goals.create(request)`. `@RemoteContext('agent')` remains the distinct scoped-receiver mode. -This phase implements Connection's shared-channel interceptor and current HTTP carrier mapping, but not WebSocket migration, the TUI runtime, a TUI carrier, or TUI Agent Scope wiring. This RFC also does not design Permission/Approval state machines, Session event streams, call authorization, cancellation, retries, idempotency, or cross-version protocol compatibility. +Connection supplies the shared-channel interceptor and current HTTP carrier mapping. WebSocket migration, the TUI runtime and carrier, TUI Agent Scope wiring, Permission/Approval state machines, Session event streams, call authorization, cancellation, retries, idempotency, and cross-version protocol compatibility remain outside this decision. ## Alternatives considered @@ -464,26 +466,24 @@ This phase implements Connection's shared-channel interceptor and current HTTP c **Create a separate transport, HTTP route, or `/api2` channel for Remote.** This would duplicate or split Connection's Server ownership, rpcId, serialization, trust, errors, and future WebSocket lifecycle. The shared `/api` interceptor instead keeps one physical route and lets Connection preserve API Proxy as the fallback FetchHandler. -## Acceptance criteria +## Verification -- Goal Service retains its existing business method and adds a remote entry point at the end of the class through an explicit `typertGateway` and `@Remote('create') remoteExportCreate(...)`, without maintaining a second route, codec, or Client method list. -- One clean `build:lib` generates the Host Remote contract before compiling Host and Client consumers and produces JS, DTS, and a DTS map under the business package's `lib`, importable through `/remote`. -- After importing `@deepseek-ai/dsh-goal/remote`, a consumer project gets a strict `api.goals.create(...)` type; without the import, that namespace does not enter its types. Go to Definition on `create` follows the declaration map to the Host Service's `remoteExportCreate` implementation. -- After the Client assembly mounts the JS contribution obtained from the same import, TypeRT can reflect endpoint, parameter, result, lookup, Context, and Zod information, and the API Service creates the calling method without a hand-written stub. -- Remote DTS, Remote JS, `RemoteApi`, and the descriptor protocol do not depend on Browser-specific capabilities, and the type model cannot expose unmarked Goal Service methods, preserving the boundary required for future isomorphic TUI integration. -- `agent.goals.*` obtains its call Scope through the Cordis tracker and Context binder. The Root Context has no Agent-only type, and functions are not copied into each Scope. -- `/api/goals/create` resolves `agentId` to the canonical Agent object, invokes the original Goal Service receiver, and returns the result through the existing RPC result/error mechanism. -- Gateway mounts into Connection, Connection mounts the single `/api` route into HTTP Server, and Remote defines neither an HTTP route nor a second response envelope. -- Connection's composite FetchHandler dispatches a TypeRT-owned endpoint to Gateway and falls back to API Proxy only when Gateway does not claim it. A withdrawn strict endpoint remains claimed and fails as unavailable. -- Existing API Proxy trust, privileged-method, Permission/Approval, and Session event stream behavior remains unchanged for unclaimed endpoints. +- Goal Service keeps its existing business method and adds an explicit `typertGateway` plus `@Remote('create') remoteExportCreate(...)`, without a second route, codec, or Client method list. +- A clean `build:lib` emits Host and consumer Remote artifacts before Client compilation, including the business package's JS, DTS, and declaration map under `/remote`. +- Importing `@deepseek-ai/dsh-goal/remote` adds the strict `api.goals.create(...)` type and declaration navigation to `remoteExportCreate`; omitting that import omits the namespace. +- Mounting the same import's JS contribution supplies endpoint, parameter, result, lookup, Context, and Zod reflection and materializes the call without a handwritten stub. +- Root and Agent-scoped calls cross the real shared `/api` carrier, resolve `agentId` to the live Agent, invoke the original Goal receiver, and return through the existing RPC envelope. +- The Remote artifacts and maps contain only marked methods and no Browser dependency, preserving the same consumer boundary for a future TUI. +- Lifecycle tests withdraw and remount descriptors, Services, lookups, Context providers, and Client namespaces; unavailable dependencies fail without stale calls or raw-ID fallback. +- Unclaimed endpoints continue through the existing API Proxy path with its trust, privileged-method, Permission/Approval, and Session event-stream behavior unchanged. -## Risks +## Consequences Remote API types depend on generated `lib` declarations. Build orchestration must finish the Host contract pass before compiling Host and Client consumers; an incorrect order makes a clean build depend on stale artifacts. Source navigation requires a Remote package to publish both its declaration map and the `src` file referenced by the map. If package `files` omits either side, types still compile but consumer navigation stops at the generated DTS. The workspace manifest check must therefore treat both as one publication contract. -The permissive SRC descriptor does not validate the internal structure of ordinary JSON. After a Host Remote signature changes, the Web and strict type consumers must rebuild the lib; the first phase has no incremental contract watcher. +The permissive SRC descriptor does not validate the internal structure of ordinary JSON. After a Host Remote signature changes, the Web and strict type consumers must rebuild the lib because no incremental contract watcher exists. Canonical public types require business DTOs to have type-only entries, which may expose packages whose Host types and implementation entries are currently mixed. The build rejects those boundaries instead of copying types to conceal them. @@ -494,3 +494,9 @@ Browser and Host each hold their own Zod instances and cannot compare object ide A consumer may import a Remote contract that is not currently mounted on the Host. The types mean "this protocol capability was selected by the consumer," not that a corresponding Service currently exists in the target process; an unavailable endpoint must fail explicitly at runtime. Connection's general channel API must suit both the current HTTP carrier and a future WebSocket carrier. If the API exposes `fetch`, an HTTP request, or a route handle to the Gateway/API Service, WebSocket migration will pierce the Remote layer again. Those physical objects must therefore remain internal to Connection. + +Remote endpoints use Connection's `trusted-host` authority. Loopback is accepted by default and LAN callers require an explicit trusted-host configuration, but this layer adds no per-method caller authorization; every trusted host can invoke a mounted Remote endpoint. + +`hasSeen()` favors strict-definition safety over SRC availability. While a strict descriptor is withdrawn, such as during HMR, the Gateway continues to claim the endpoint and reports it unavailable instead of falling back to a weak SRC descriptor. Re-registration restores it; only a TypeRT registry restart forgets the historical strict definition. + +Connection supplies an `AbortSignal`, but Remote business signatures have no cancellation parameter. A client disconnect therefore does not cancel business work; cancellation remains deferred rather than being implied by the transport handler shape. diff --git a/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md similarity index 85% rename from .agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.zh.md rename to .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index 1e09965d2b..73abd53109 100644 --- a/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -1,6 +1,6 @@ # Agent Note: TypeRT Gateway 定向方法调用 -Status: proposed +Status: implemented [English](2026-08-02-typert-remote-method-calls.md) | 中文 @@ -8,13 +8,13 @@ Status: proposed Host API Proxy 同时承担直接方法调用、带状态交互和 Session 事件流。三者的生命周期、路由语义和客户端编程界面不同,继续共用一个业务导出包会让业务 Service、传输协议、状态机和客户端类型彼此耦合。 -本方案只解决一次请求对应一次结果的定向方法调用。Permission、Approval 等带状态交互以及 Session 事件流不使用本方案,后续分别设计。 +本决策只涵盖一次请求对应一次结果的定向方法调用。Permission、Approval 等带状态交互以及 Session 事件流仍采用独立设计。 -直接方法调用的契约属于实现该行为的业务 Service。业务开发者应只声明哪些方法可以远程调用,而不应再同步维护中央 API 接口、路由表、参数转换表、客户端 stub 和 Zod schema。 +直接方法调用的契约属于实现该行为的业务 Service。业务开发者只需声明哪些方法可以远程调用,无需再同步维护中央 API 接口、路由表、参数转换表、客户端 stub 和 Zod schema。 Host 与 Browser Client 使用独立的 TypeScript Program,因为两边会以不同类型合并同名 Cordis `Context`。Remote 投影不能把完整 Host 声明导入消费端,也不能依赖 Browser 专属类型;未来 TUI 若复用这套编程界面,也只能看到 Remote 标记的方法。本期不实现 TUI 接入,但实现边界不得阻断这种同构复用。 -## Proposal +## 决策 业务 Service 通过 `@Remote` 或 `@RemoteContext()` 声明可调用方法,并通过 `bindTypeRTGateway()` 显式加入 Gateway。TypeRT 从 Host Program 生成 Host 本地反射产物和平台无关的 Remote 消费端投影;Client Program 继续独立生成自己的本地反射产物。 @@ -24,7 +24,7 @@ Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只 ## 组件和 Cordis 服务 -| 组件 | Cordis 服务 | 本方案中的职责 | +| 组件 | Cordis 服务 | 职责 | |---|---|---| | `@deepseek-ai/dsh-type-meta` | 只声明 `ctx.typert` 的最小协议 | decorator、binding、descriptor、lookup/Context 和 Remote map;不依赖 compiler、Zod、Connection 或 Browser | | TypeRT registry | `ctx.typert` | 分开保存当前环境 reflection、导入的 Remote contribution、lookup provider 和 Context provider | @@ -104,7 +104,7 @@ ctx.typert.lookups.register('agent', { 静态声明让 TypeRT 知道 `Agent` 在 wire 上对应 `SessionId`;运行时 provider 负责把请求中的 `agentId` 解析为当前活的 `Agent` 对象。缺少任一侧时,LIB 构建或最早可解析的运行时注册直接失败。 -Agent、Session 等 lookup 对象只能各自占据一个顶层参数位置。普通 JSON request 可以作为另一个完整参数传入,但本方案不支持 `request.agent`、对象解构、对象数组、嵌套 lookup 或从任意复杂结构中搜索 ID。 +Agent、Session 等 lookup 对象只能各自占据一个顶层参数位置。普通 JSON request 可以作为另一个完整参数传入,但本设计不支持 `request.agent`、对象解构、对象数组、嵌套 lookup 或从任意复杂结构中搜索 ID。 Remote Context 使用独立的 merge-extensible map 和 provider。Agent 包注册 `agent` Context provider,负责用 wire identity 找到 Agent Context,并从该 Context 解析 descriptor 指定的 service key;Gateway 不知道 Agent Context 的内部结构。 @@ -150,7 +150,9 @@ ctx.typert.lookups wire ID 到 Host 活对象的 provider ctx.typert.contexts Host Context resolver 与 Client Context binder ``` -每次注册都返回由调用方 Cordis fiber 持有的 disposer。Gateway 和 API Service 先读取当前快照再订阅变化,因此业务 Service、generated contribution、provider 和消费者可以按任意顺序加载;任一依赖 dispose 后,相关 endpoint 或方法立即失效。 +每次注册都返回由调用方 Cordis fiber 持有的 disposer。挂载 Client contribution 时,descriptor 集与具体方法会作为一项有明确所有者的操作统一注册。Host Gateway 每次认领和调用时都从当前状态解析 descriptor、Service 与提供方,不保留 endpoint 注册。因此移除 strict definition、Service 或提供方会使相应调用不可用,且不会留下陈旧的活对象。 + +lookup 注册表会在活 resolver 卸载后保留稳定的 wire 声明。SRC 解析仍会把该参数归类为 lookup,而调用会以 `lookup-unavailable` 失败;系统绝不会把传入的 ID 重新归类为普通 JSON 业务对象。在同一个 TypeRT Service 的生命周期内,以不同参数、wire 或规范类型 symbol 重新注册同一 key 会直接失败。 Registry 的 Host 根入口拥有完整 `TypeRTService` interface merge;Host 与 Client 共用的 registry 实现位于无环境声明的独立模块。Registry `/client` 入口只引用该共享实现,不经过 Host 根入口,因此不会把 Host Cordis 声明带入 Client Program。 @@ -312,11 +314,11 @@ agent.goals.create(request) Root `Context` 不 merge scoped `goals` 类型;只有 `AgentContext` 通过 `RemoteContextApi<'agent'>` 获得该属性。若调用方绕过类型从 Root 动态调用 scoped 方法,binder 明确报错。若 Client 已有同名 Cordis service,或两个 contribution 冲突占用同一 namespace/method,mount 直接失败,不覆盖现有服务。 -生成的 Remote JS 只包含 descriptor、symbol key 和 codec,不打包 Host Service 实现。API Service 可以据此创建真实函数,因此本方案不依赖 JavaScript Proxy;Proxy 可以作为实现选择,但不会成为类型或反射来源。 +生成的 Remote JS 只包含 descriptor、symbol key 和 codec,不打包 Host Service 实现。API Service 据此创建真实函数,因此运行时不依赖 JavaScript Proxy;Proxy 可以作为实现选择,但不会成为类型或反射来源。 ## 跨环境同构约束 -Remote API 是消费端能力,不等同于 Browser API。本期只实现 Browser Client 的 contribution 挂载、Connection RPC 调用和 Agent Scope 关联。 +Remote API 是消费端能力,不等同于 Browser API。已交付的运行时实现 Browser Client contribution 挂载、Connection RPC 调用和 Agent Scope 关联。 Remote DTS、Remote JS、`RemoteApi`、`InvocationDescriptor`、Remote RPC 数据协议和 Context binder 不得依赖 DOM、Browser module loader 或 HTTP。Browser Client 通过 Connection 把 descriptor 实体化的方法编码为 `/api` RPC 调用。 @@ -324,7 +326,7 @@ Remote DTS、Remote JS、`RemoteApi`、`InvocationDescriptor`、Remote RPC 数 TUI 的 runtime 挂载、carrier、Agent Scope 关联和 SRC 启动接线均不属于本期实现。 -Web 本身依赖 `lib/client.js` 等构建产物,因此启动 Web 前要求完整 `build:lib`。Host Remote 契约变化后必须重新执行 lib build,再启动或重启 Web;本方案不在第一阶段实现 Remote contract 的增量 watch。 +Web 本身依赖 `lib/client.js` 等构建产物,因此启动 Web 前要求完整 `build:lib`。Host Remote 契约变化后,开发者需重新执行 lib build,再启动或重启 Web;系统不实现 Remote contract 的增量 watch。 ## SRC 与 LIB 运行模式 @@ -340,11 +342,11 @@ LIB 运行时只加载 `lib` 中的 definition,不启动 TypeScript compiler CI 和发布运行 LIB。全仓 coverage 全部切换到 LIB 是独立后续工作,不阻塞本次直接方法调用实现。 -## Host Gateway 注册 +## Host Gateway 解析 -Host Gateway 同时观察 TypeRT Remote definition 和 Cordis Service 生命周期。当某个带 `typertGateway` facet 的 Service 与同 service key 的 definition 都可用时,Gateway 注册其 endpoint;两者到达顺序不影响结果。 +Host Gateway 向 Connection 注册一个 `/api` interceptor,不维护第二份 endpoint 注册表。ownership matcher 会从当前 TypeRT local 注册表解析各 endpoint,或扫描当前 Cordis Service,查找匹配的 `typertGateway` binding 与 SRC Remote 标记。因此 TypeRT definition 与业务 Service 可以按任意顺序到达。 -Gateway 启动时先读取 TypeRT definition 和 Cordis reflection store 的当前快照,再订阅 registry change 与 `internal/service`。它按 service key reconcile definition、活 Service 和 binding;Service 被替换或 dispose 时撤销对应 endpoint。definition、lookup provider 或 Context provider 撤销时,依赖它们的 endpoint 立即不可调用,不保留失效对象或降级为原始 ID 调用。 +每次调用都会重新从当前状态解析 descriptor、receiver、lookup 提供方与 Context 提供方。当前 strict descriptor 优先于 SRC。strict endpoint 一旦出现,即使随后撤回对应 descriptor,`TypeRTLocalRegistry.hasSeen()` 仍会在注册表剩余生命周期内保持对它的认领并禁止回退 SRC;重新注册 strict descriptor 即可恢复调用。移除 Service 或提供方会让调用明确失败;Gateway 既不保留失效对象,也不会以原始 lookup ID 调用方法。 普通 `@Remote` 调用保留原始 Service 实例作为 receiver。lookup 成功后,Gateway 按 descriptor 的参数顺序调用 `implementation ?? method` 指定的成员。 @@ -417,7 +419,7 @@ ctx.api.goals.create(sessionId, request) → Client result codec 验证并返回 CreateGoalResult ``` -Remote 不定义第二层 `{ ok, value/error }` response。成功值和 Gateway 错误直接使用既有 RPC response 的 `result`;Gateway adapter 负责把 endpoint、schema、lookup、Context、Service 和业务调用失败映射为 `RpcError`,Connection 负责传输该错误。 +Remote 不定义第二层 `{ ok, value/error }` response。成功值和 Gateway 错误直接使用既有 RPC response 的 `result`。当前 adapter 把所有 Gateway 与业务调用失败转换为既有 `RpcError` envelope,并统一使用 `code: 'internal'`;Gateway 的结构化错误分类仅在进程内保留,诊断信息则通过 message 跨 Connection 传递。 Gateway 不处理逐方法权限、调用者身份、取消、幂等或长连接状态。TypeRT endpoint 使用 Connection 的 trusted-host 策略;未认领 endpoint 保留旧 API Proxy 的 trust 和 privileged-method 策略。Connection/WebSocket 迁移后续独立完成。 @@ -438,11 +440,11 @@ Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 H - Agent/Session 等业务对象包:拥有 lookup、Context provider、唯一 ID 类型和纯类型公共出口。 - 业务 Service 包:声明 binding、Remote 方法及其 request/result 类型,并导出生成的 `/remote` 子路径。 -## 首期实现范围 +## 已交付范围与后续工作 -第一条纵向链路实现 `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`,并证明同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.api.goals.create(agentId, request)` 与 `agentCtx.goals.create(request)`。`@RemoteContext('agent')` 的 scoped receiver 语义继续保留为独立模式。 +已交付的纵向链路是 `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`。同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.api.goals.create(agentId, request)` 与 `agentCtx.goals.create(request)`。`@RemoteContext('agent')` 仍是独立的 scoped receiver 模式。 -本期实现 Connection 的共享 channel interceptor 及当前 HTTP carrier 映射,但不实现 WebSocket 迁移、TUI runtime、TUI carrier 或 TUI Agent Scope 接线。本 RFC 也不设计 Permission/Approval 状态机、Session 事件流、调用授权、取消、重试、幂等和跨版本协议兼容。 +Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebSocket 迁移、TUI runtime 与 carrier、TUI Agent Scope 接线、Permission/Approval 状态机、Session 事件流、调用授权、取消、重试、幂等及跨版本协议兼容均不属于本决策。 ## Alternatives considered @@ -464,26 +466,24 @@ Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 H **为 Remote 新建独立 transport、HTTP route 或 `/api2` channel。** 这会复制或拆分 Connection 的 Server ownership、rpcId、序列化、trust、错误和未来 WebSocket 生命周期。共享 `/api` interceptor 保留唯一物理 route,并让 Connection 继续以 API Proxy 作为回退 FetchHandler。 -## Acceptance criteria +## 验证 -- Goal Service 保留既有业务方法,在类末尾通过显式 `typertGateway` 和 `@Remote('create') remoteExportCreate(...)` 新增远程出口,不维护第二份路由、codec 或 Client 方法清单。 -- 一次干净 `build:lib` 先生成 Host Remote contract,再完成 Host 和 Client 消费端编译,并在业务包 `lib` 下产生可通过 `/remote` 导入的 JS、DTS 和 DTS map。 -- 导入 `@deepseek-ai/dsh-goal/remote` 后,消费 project 获得严格的 `api.goals.create(...)` 类型;不导入时该 namespace 不进入类型;从 `create` 跳转定义会通过 declaration map 到达 Host Service 的 `remoteExportCreate` 实现。 -- Client assembly 挂载同一个 import 得到的 JS contribution 后,TypeRT 能反射 endpoint、参数、结果、lookup、Context 和 Zod 信息,API Service 无需手写 stub 即可创建调用方法。 -- Remote DTS、Remote JS、`RemoteApi` 和 descriptor 协议不依赖 Browser 专属能力,且类型模型无法暴露未标记的 Goal Service 方法,为未来 TUI 同构接入保留边界。 -- `agent.goals.*` 通过 Cordis tracker 和 Context binder 取得调用 Scope,Root Context 不获得 Agent-only 类型,且不为每个 Scope 复制函数。 -- `/api/goals/create` 能把 `agentId` 解析为唯一 Agent 对象,调用原始 Goal Service receiver,并通过既有 RPC result/error 返回结果。 -- Gateway 挂到 Connection,Connection 把唯一 `/api` route 挂到 HTTP Server;Remote 不定义 HTTP route 或第二套 response envelope。 -- Connection 的复合 FetchHandler 将 TypeRT 认领的 endpoint 分发给 Gateway,仅在 Gateway 不认领时回退 API Proxy;已撤回的 strict endpoint 继续被认领并返回 unavailable。 -- 未认领 endpoint 保留既有 API Proxy trust、privileged-method、Permission/Approval 和 Session 事件流行为。 +- Goal Service 保留既有业务方法,并新增显式 `typertGateway` 与 `@Remote('create') remoteExportCreate(...)`,无需第二条路由、第二份 codec 或 Client 方法清单。 +- 一次干净的 `build:lib` 会在 Client 编译前生成 Host 与消费方 Remote 产物,包括业务包 `/remote` 下的 JS、DTS 和 declaration map。 +- 导入 `@deepseek-ai/dsh-goal/remote` 会加入严格的 `api.goals.create(...)` 类型,并可通过 declaration 导航到 `remoteExportCreate`;不导入时不会出现该 namespace。 +- 挂载同一次 import 得到的 JS contribution 会提供 endpoint、参数、结果、lookup、Context 和 Zod 反射,并在无需手写 stub 的情况下实体化调用。 +- Root 与 Agent-scoped 调用会经过真实的共享 `/api` carrier,将 `agentId` 解析为活 Agent,调用原始 Goal receiver,并通过既有 RPC envelope 返回。 +- Remote 产物与 map 仅包含已标记的方法,不依赖 Browser,从而为未来 TUI 保留相同的消费方边界。 +- 生命周期测试会撤回并重新挂载 descriptor、Service、lookup、Context 提供方和 Client namespace;依赖不可用时,调用会失败,且不会使用陈旧调用或回退原始 ID。 +- 未认领 endpoint 继续使用既有 API Proxy 路径,其 trust、privileged-method、Permission/Approval 与 Session 事件流行为保持不变。 -## Risks +## 后果 Remote API 类型依赖生成的 `lib` 声明,构建编排必须在 Host 和 Client 消费端编译前完成 contract pass;顺序错误会让干净构建依赖陈旧产物。 源码导航依赖 Remote package 同时发布 declaration map 和 map 指向的 `src`。package `files` 漏掉任一侧时类型仍可编译,但消费端跳转会停在生成 DTS,因此 workspace manifest 校验必须把两者作为同一发布契约。 -SRC 弱 descriptor 不验证普通 JSON 内部结构。Host Remote 签名变化后,Web 和严格类型消费者必须重新执行 lib build;第一阶段没有增量 contract watch。 +SRC 弱 descriptor 不验证普通 JSON 内部结构。Host Remote 签名变化后,Web 和严格类型消费方必须重新执行 lib build,因为系统没有增量 contract watcher。 公共类型唯一性要求业务 DTO 具有纯类型出口,可能暴露现有包中 Host 类型与实现入口混杂的问题。构建会拒绝这些边界,而不是复制类型掩盖问题。 @@ -494,3 +494,9 @@ Browser 与 Host 各自持有 Zod 实例,不能依赖对象 identity 跨 realm 消费端可以导入 Host 当前未挂载的 Remote contract。类型表示“该协议能力已被消费端选择”,不保证目标进程当前存在对应 Service;运行时 endpoint 不可用必须明确失败。 Connection 的通用 channel API 必须同时适合当前 HTTP carrier 和后续 WebSocket carrier。若接口把 `fetch`、HTTP request 或 route handle 暴露给 Gateway/API Service,WebSocket 迁移会再次穿透 Remote 层,因此这些物理对象必须留在 Connection 内部。 + +Remote endpoint 使用 Connection 的 `trusted-host` authority。系统默认接受 loopback;LAN 调用方必须通过显式 trusted-host 配置接入,但本层不增加逐方法调用方授权,因此每个 trusted host 都能调用已挂载的 Remote endpoint。 + +`hasSeen()` 优先保障 strict definition 的安全性,而非 SRC 可用性。strict descriptor 撤回时(例如 HMR 期间),Gateway 会继续认领 endpoint 并报告不可用,而不会回退到弱 SRC descriptor。重新注册即可恢复;只有重启 TypeRT 注册表才会忘记历史 strict definition。 + +Connection 提供 `AbortSignal`,但 Remote 业务签名没有取消参数。因此 Client 断连不会取消业务工作;取消仍作为后续工作,而不能由 transport handler 的形状暗示已经支持。 diff --git a/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml deleted file mode 100644 index 6e7a1a3a13..0000000000 --- a/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.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-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: 61c8f61468621846fa8e8ff78d52313ae805aa17 -2026-08-02-typert-remote-method-calls.zh.md: 1e09965d2baba2db35301288f338cef15d947f36 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 08f26479ca..41c9c98515 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -308,7 +308,7 @@ export interface ConnectionConfig { } ``` -Source: [`packages/client/connection/src/index.ts:31`](../packages/client/connection/src/index.ts) +Source: [`packages/client/connection/src/index.ts:32`](../packages/client/connection/src/index.ts) ## `@deepseek-ai/dsh-client-hmr` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index a0fee79546..99ffaca7c7 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2634,7 +2634,7 @@ listPackages(filter: TypertPackageFilter = {}): TypertPackageRecord[] toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema.BaseSchema ``` -Source: [`packages/typert/registry/src/service.ts:324`](../../packages/typert/registry/src/service.ts) +Source: [`packages/typert/registry/src/service.ts:346`](../../packages/typert/registry/src/service.ts) ## `ctx.typertGateway` — `TypertGatewayService` diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index a048f4e43d..461d1ef4fc 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/core.md -core.md: eb96988abe096455c4f24ac220a6da3f266e690d -core.zh.md: 7334b3d3a5bd088f5467a72d7357f87c4c745487 +core.md: f7cf288715a3aec2f7037f12fc983e3172a77cef +core.zh.md: c17fd1335503c95e7f7f6f96cc286f567a8384e6 diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index eb96988abe..f7cf288715 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -20,6 +20,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [llm-streaming.md](llm-streaming.md) | the `StreamChunk` wire protocol + adapter contract, `BlockAssembler`, the `LlmAdapter` seam | | [token-meter.md](token-meter.md) | immutable scalar and positional replay measurements with consumed-log revisions | | [scope.md](scope.md) | scoped registration identity, dispatch carriers, and the owned `Scope` context | +| [typert.md](typert.md) | Remote invocation descriptors, lookup/Context declarations, TypeRT registries, and the Host Gateway/Client API seams | | [goal.md](goal.md) | persisted goal identity, lifecycle snapshots, activation, change records, and round attribution | | [commands.md](commands.md) | the human-command seam: definitions, adapter discovery, direct invocation, results, and parsing views | | [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnTrigger`/`TurnEndReason`, `deriveMessages()`, execution enclosure, and standalone events | diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index 7334b3d3a5..c17fd13355 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -20,6 +20,7 @@ harness 是一个微内核:一个极小的核心加上众多插件。大多数 | [llm-streaming.md](llm-streaming.md) | `StreamChunk` 协议格式(wire format)+ 适配器契约(adapter contract)、`BlockAssembler`、`LlmAdapter` seam | | [token-meter.md](token-meter.md) | 不可变的标量与位置回放度量,附带已消费日志修订号 | | [scope.md](scope.md) | 作用域注册标识、dispatch 载体,以及拥有的 `Scope` 上下文 | +| [typert.md](typert.md) | Remote 调用 descriptor、lookup/Context 声明、TypeRT 注册表,以及 Host Gateway/Client API seam | | [goal.md](goal.md) | 持久 goal 标识、生命周期快照、激活、变更记录与 Round 归属 | | [commands.md](commands.md) | 人类命令 seam:定义、适配器发现、直接调用、结果与解析视图 | | [session.md](session.md) | 完整的 `SessionEventMap` 变体目录、`TurnTrigger`/`TurnEndReason`、`deriveMessages()`、执行封闭与独立事件 | diff --git a/docs/core-data-structures/typert.i18n.yaml b/docs/core-data-structures/typert.i18n.yaml new file mode 100644 index 0000000000..be40eeb20a --- /dev/null +++ b/docs/core-data-structures/typert.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 docs/core-data-structures/typert.md +typert.md: 9f5c63fc554a43fd0248ed08a64dcff566c83b58 +typert.zh.md: 2b74c8325a510ba39d134fa6d463dab273239772 diff --git a/docs/core-data-structures/typert.md b/docs/core-data-structures/typert.md new file mode 100644 index 0000000000..9f5c63fc55 --- /dev/null +++ b/docs/core-data-structures/typert.md @@ -0,0 +1,196 @@ +# TypeRT remote calls + +English | [中文](typert.zh.md) + +Types shared by generated Remote artifacts, the Host Gateway, and consumer API assemblies. The [TypeRT Gateway Agent Note](../../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) owns the architecture and transport decisions; this page records the literal public contracts from [`dsh-type-meta`](../../packages/typert/type-meta/src/types.ts) and [`dsh-host-api-gateway`](../../packages/host/api-gateway/src/types.ts). + +## Lookup and Context declarations + +Business-object packages extend two empty maps through declaration merging. A lookup associates one Host object type with its wire identity; a Context declaration associates one scoped Context kind with its wire identity. Generated descriptors name these keys, while runtime providers supply the live resolution behavior. + +```ts type-equiv +/** Merge-extensible Host object lookup declarations. */ +interface TypeRTLookupMap {} +``` + +```ts type-equiv +/** Merge-extensible scoped Context declarations. */ +interface TypeRTContextMap {} +``` + +The registry retains a lookup's wire declaration after its resolver unloads. SRC discovery therefore continues to classify the parameter as a lookup and fails unavailable instead of accepting the wire value as an ordinary business object. + +```ts type-equiv +/** Stable wire declaration retained after a lookup provider unloads. */ +interface TypeRTLookupDefinition { + /** Merge-declared lookup key. */ + readonly key: string + /** Source parameter name recognized by the SRC weak parser. */ + readonly parameter: string + /** Wire field replacing the Host object parameter. */ + readonly wire: string + /** Canonical Host type symbol used by strict generation. */ + readonly hostTypeSymbol: string + /** Canonical wire type symbol used by strict generation. */ + readonly wireTypeSymbol: string +} +``` + +## Invocation descriptors + +An `InvocationDescriptor` is local reflection, not a wire message. Host and consumer builds generate corresponding descriptors; the request sends only the endpoint and named `args`. Strict codecs carry generated schemas, while SRC codecs enforce JSON-safe values without structural type recovery. + +```ts type-equiv +/** Codec attached to one invocation parameter or result. */ +type TypeRTCodec = + | { + readonly mode: 'strict' + readonly typeSymbol: string + readonly schema: TypeRTSchema + } + | { + readonly mode: 'src-json' + } +``` + +```ts type-equiv +/** One ordered business parameter in a Remote invocation. */ +interface InvocationParameterDescriptor { + /** Source-level parameter name. */ + readonly name: string + /** Required key in the wire `args` object. */ + readonly wire: string + /** Whether the value is JSON or requires a registered Host lookup. */ + readonly source: 'json' | 'lookup' + /** Lookup key when `source` is `lookup`. */ + readonly lookup?: string + /** Boundary codec for the wire representation. */ + readonly codec: TypeRTCodec +} +``` + +```ts type-equiv +/** Carrier-independent description of one exported method invocation. */ +interface InvocationDescriptor { + /** Globally stable generated identity. */ + readonly id: string + /** Cordis service key owning the method. */ + readonly service: string + /** Wire namespace, defaulting to the service key. */ + readonly namespace: string + /** Public instance method name. */ + readonly method: string + /** Service member invoked when the exported method name is an alias. */ + readonly implementation?: string + /** Receiver selection mode. */ + readonly invocation: + | { readonly kind: 'direct' } + | { + readonly kind: 'context' + readonly context: string + readonly wire: string + readonly codec: TypeRTCodec + } + /** Optional consuming-Context projection for one direct lookup parameter. */ + readonly scope?: { + /** Context kind whose Client binder supplies the identity. */ + readonly context: string + /** Lookup parameter wire field replaced by the Context identity. */ + readonly wire: string + } + /** Ordered business parameters. */ + readonly parameters: readonly InvocationParameterDescriptor[] + /** Codec for the resolved method result. */ + readonly result: TypeRTCodec + /** Source declaration used only for diagnostics. */ + readonly sourceLocation?: InvocationSourceLocation +} +``` + +## TypeRT registry + +`ctx.typert` separates current-environment descriptors, explicitly selected Remote contributions, live lookup providers, and scoped Context providers. Registrations are Cordis-owned effects and return awaitable disposers. + +```ts type-equiv +/** Minimal TypeRT runtime consumed through dependency inversion. */ +interface TypeRTService { + readonly local: TypeRTLocalRegistry + readonly remotes: TypeRTRemoteRegistry + readonly lookups: TypeRTLookupRegistry + readonly contexts: TypeRTContextRegistry +} +``` + +Generated consumer declarations merge direct namespaces into the map inherited by `ClientApi`. + +```ts type-equiv +/** Merge-extensible direct namespace surface generated for Client API services. */ +interface TypeRTRemoteNamespaceMap {} +``` + +## Host Gateway + +Connection decodes its carrier envelope before calling `ctx.typertGateway`. The request carries exact named wire fields; infrastructure and boundary failures use the Gateway's in-process error taxonomy, although the current RPC adapter folds them into the transport's `internal` error code. + +```ts type-equiv +/** One Remote method request after a carrier has decoded its envelope. */ +interface InvokeRemoteRequest { + /** Remote namespace selected by the generated descriptor. */ + readonly namespace: string + /** Exported Service method name. */ + readonly method: string + /** Named wire values; fields must exactly match the descriptor. */ + readonly args: Readonly> +} +``` + +```ts type-equiv +/** Stable infrastructure and boundary failures emitted before or after business execution. */ +type TypertGatewayErrorCode = + | 'ambiguous-endpoint' + | 'arguments-invalid' + | 'binding-invalid' + | 'context-failed' + | 'context-not-found' + | 'context-unavailable' + | 'definition-unavailable' + | 'input-invalid' + | 'invocation-unavailable' + | 'lookup-failed' + | 'lookup-not-found' + | 'lookup-unavailable' + | 'method-unavailable' + | 'provider-mismatch' + | 'result-invalid' + | 'service-unavailable' + | 'signature-invalid' +``` + +```ts type-equiv +/** Host dispatcher consumed by Connection adapters. */ +interface TypertGateway { + /** + * Invoke one live Remote method without assuming a carrier or response envelope. + * @param request - decoded endpoint and named wire arguments. + * @returns the validated business result. + * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; business errors retain their identity. + */ + invoke(request: InvokeRemoteRequest): Promise +} +``` + +## Consumer API + +`ctx.api` exposes only namespaces contributed by imported `/remote` artifacts. Mounting installs the generated descriptors and concrete root/scoped methods as one fiber-owned operation; no JavaScript Proxy or Host Service type enters the consumer. + +```ts type-equiv +/** Typed API service augmented by generated direct Remote namespaces. */ +interface ClientApi extends TypeRTRemoteNamespaceMap { + /** + * Mount one generated Host-for-Client contribution in the caller's fiber. + * @param contribution - explicitly selected Remote package artifact. + * @returns disposer withdrawing descriptors and concrete methods together. + */ + mount(contribution: TypeRTRemoteContribution): TypeRTDisposer +} +``` diff --git a/docs/core-data-structures/typert.zh.md b/docs/core-data-structures/typert.zh.md new file mode 100644 index 0000000000..2b74c8325a --- /dev/null +++ b/docs/core-data-structures/typert.zh.md @@ -0,0 +1,196 @@ +# TypeRT 远程调用 + +[English](typert.md) | 中文 + +以下类型由生成的 Remote 产物、Host Gateway 与消费方 API assembly 共用。[TypeRT Gateway Agent Note](../../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) 负责架构与传输决策;本页记录 [`dsh-type-meta`](../../packages/typert/type-meta/src/types.ts) 和 [`dsh-host-api-gateway`](../../packages/host/api-gateway/src/types.ts) 中公共契约的字面定义。 + +## Lookup 与 Context 声明 + +业务对象包通过声明合并扩展两个空 map。lookup 将一种 Host 对象类型与其 wire identity 关联;Context 声明将一种 scoped Context 类别与其 wire identity 关联。生成的 descriptor 引用这些 key,运行时提供方则提供活对象解析行为。 + +```ts type-equiv +/** Merge-extensible Host object lookup declarations. */ +interface TypeRTLookupMap {} +``` + +```ts type-equiv +/** Merge-extensible scoped Context declarations. */ +interface TypeRTContextMap {} +``` + +lookup 的 resolver 卸载后,注册表仍会保留其 wire 声明。因此 SRC 发现过程会继续把该参数归类为 lookup,并因不可用而失败,而不会把 wire 值当作普通业务对象接受。 + +```ts type-equiv +/** Stable wire declaration retained after a lookup provider unloads. */ +interface TypeRTLookupDefinition { + /** Merge-declared lookup key. */ + readonly key: string + /** Source parameter name recognized by the SRC weak parser. */ + readonly parameter: string + /** Wire field replacing the Host object parameter. */ + readonly wire: string + /** Canonical Host type symbol used by strict generation. */ + readonly hostTypeSymbol: string + /** Canonical wire type symbol used by strict generation. */ + readonly wireTypeSymbol: string +} +``` + +## 调用 descriptor + +`InvocationDescriptor` 是本地反射信息,不是 wire message。Host 与消费方构建会生成彼此对应的 descriptor;请求只发送 endpoint 与具名 `args`。strict codec 携带生成的 schema,SRC codec 则在不恢复结构类型的前提下强制要求 JSON 安全值。 + +```ts type-equiv +/** Codec attached to one invocation parameter or result. */ +type TypeRTCodec = + | { + readonly mode: 'strict' + readonly typeSymbol: string + readonly schema: TypeRTSchema + } + | { + readonly mode: 'src-json' + } +``` + +```ts type-equiv +/** One ordered business parameter in a Remote invocation. */ +interface InvocationParameterDescriptor { + /** Source-level parameter name. */ + readonly name: string + /** Required key in the wire `args` object. */ + readonly wire: string + /** Whether the value is JSON or requires a registered Host lookup. */ + readonly source: 'json' | 'lookup' + /** Lookup key when `source` is `lookup`. */ + readonly lookup?: string + /** Boundary codec for the wire representation. */ + readonly codec: TypeRTCodec +} +``` + +```ts type-equiv +/** Carrier-independent description of one exported method invocation. */ +interface InvocationDescriptor { + /** Globally stable generated identity. */ + readonly id: string + /** Cordis service key owning the method. */ + readonly service: string + /** Wire namespace, defaulting to the service key. */ + readonly namespace: string + /** Public instance method name. */ + readonly method: string + /** Service member invoked when the exported method name is an alias. */ + readonly implementation?: string + /** Receiver selection mode. */ + readonly invocation: + | { readonly kind: 'direct' } + | { + readonly kind: 'context' + readonly context: string + readonly wire: string + readonly codec: TypeRTCodec + } + /** Optional consuming-Context projection for one direct lookup parameter. */ + readonly scope?: { + /** Context kind whose Client binder supplies the identity. */ + readonly context: string + /** Lookup parameter wire field replaced by the Context identity. */ + readonly wire: string + } + /** Ordered business parameters. */ + readonly parameters: readonly InvocationParameterDescriptor[] + /** Codec for the resolved method result. */ + readonly result: TypeRTCodec + /** Source declaration used only for diagnostics. */ + readonly sourceLocation?: InvocationSourceLocation +} +``` + +## TypeRT 注册表 + +`ctx.typert` 分开保存当前环境的 descriptor、显式选择的 Remote contribution、活 lookup 提供方与 scoped Context 提供方。各项注册都是由 Cordis 持有的 effect,并返回可等待的 disposer。 + +```ts type-equiv +/** Minimal TypeRT runtime consumed through dependency inversion. */ +interface TypeRTService { + readonly local: TypeRTLocalRegistry + readonly remotes: TypeRTRemoteRegistry + readonly lookups: TypeRTLookupRegistry + readonly contexts: TypeRTContextRegistry +} +``` + +生成的消费方声明会把 direct namespace 合并到 `ClientApi` 继承的 map 中。 + +```ts type-equiv +/** Merge-extensible direct namespace surface generated for Client API services. */ +interface TypeRTRemoteNamespaceMap {} +``` + +## Host Gateway + +Connection 会先解码 carrier envelope,再调用 `ctx.typertGateway`。请求携带精确的具名 wire 字段;基础设施与边界失败使用 Gateway 的进程内错误分类体系,但当前 RPC 适配器会把这些错误折叠为传输层的 `internal` 错误码。 + +```ts type-equiv +/** One Remote method request after a carrier has decoded its envelope. */ +interface InvokeRemoteRequest { + /** Remote namespace selected by the generated descriptor. */ + readonly namespace: string + /** Exported Service method name. */ + readonly method: string + /** Named wire values; fields must exactly match the descriptor. */ + readonly args: Readonly> +} +``` + +```ts type-equiv +/** Stable infrastructure and boundary failures emitted before or after business execution. */ +type TypertGatewayErrorCode = + | 'ambiguous-endpoint' + | 'arguments-invalid' + | 'binding-invalid' + | 'context-failed' + | 'context-not-found' + | 'context-unavailable' + | 'definition-unavailable' + | 'input-invalid' + | 'invocation-unavailable' + | 'lookup-failed' + | 'lookup-not-found' + | 'lookup-unavailable' + | 'method-unavailable' + | 'provider-mismatch' + | 'result-invalid' + | 'service-unavailable' + | 'signature-invalid' +``` + +```ts type-equiv +/** Host dispatcher consumed by Connection adapters. */ +interface TypertGateway { + /** + * Invoke one live Remote method without assuming a carrier or response envelope. + * @param request - decoded endpoint and named wire arguments. + * @returns the validated business result. + * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; business errors retain their identity. + */ + invoke(request: InvokeRemoteRequest): Promise +} +``` + +## 消费方 API + +`ctx.api` 只暴露由已导入 `/remote` 产物贡献的 namespace。挂载会把生成的 descriptor 与具体的 root/scoped 方法作为一项由 fiber 持有的操作统一注册;JavaScript Proxy 与 Host 服务类型都不会进入消费方。 + +```ts type-equiv +/** Typed API service augmented by generated direct Remote namespaces. */ +interface ClientApi extends TypeRTRemoteNamespaceMap { + /** + * Mount one generated Host-for-Client contribution in the caller's fiber. + * @param contribution - explicitly selected Remote package artifact. + * @returns disposer withdrawing descriptors and concrete methods together. + */ + mount(contribution: TypeRTRemoteContribution): TypeRTDisposer +} +``` diff --git a/package.json b/package.json index 9d0cac6d5e..a327e36ec3 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "build:web": "pnpm --filter @deepseek-ai/dsh-frontend run build", "clean": "tsx scripts/clean.ts", "change-scope": "tsx scripts/change-scope.ts", - "typecheck": "tsc -b", + "typecheck": "npm run build:lib:contracts && tsc -b", "lint": "tsx scripts/run-oxlint.ts .", "lint:fix": "eslint --config eslint.format.config.mjs --fix . && tsx scripts/run-oxlint.ts . --fix", "duplication": "jscpd --config .jscpd.json packages scripts", diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 48f4aadeed..4fe2b12323 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -3097,7 +3097,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'TypertContribution', - declaration: 'export interface TypertContribution {\n readonly package: string;\n readonly face: TypertFace;\n readonly schemas: readonly TypertSchema[];\n readonly model: TypertPackageModel;\n readonly invocations?: readonly InvocationDescriptor[];\n}', + declaration: 'export interface TypertContribution {\n readonly package: string;\n readonly face: TypertFace;\n readonly schemas: readonly TypertSchema[];\n readonly model: TypertPackageModel;\n readonly invocations: readonly InvocationDescriptor[];\n}', }, { name: 'TypeRTDisposer', diff --git a/packages/host/api-gateway/src/client/index.ts b/packages/host/api-gateway/src/client/index.ts index 1f92bc0748..5cd8ab75d1 100644 --- a/packages/host/api-gateway/src/client/index.ts +++ b/packages/host/api-gateway/src/client/index.ts @@ -284,6 +284,7 @@ class ScopedRemoteNamespace extends Service { install(descriptor: InvocationDescriptor, projection: ScopedProjection, token: MountToken): void { this.assertMethodAvailable(descriptor.method) + if (this.methods.size === 0) this.ownerCtx.set(this.name, this) const method = descriptor.method Object.defineProperty(this, method, { configurable: true, diff --git a/packages/host/api-gateway/src/index.ts b/packages/host/api-gateway/src/index.ts index 2adfaa8387..64d5715719 100644 --- a/packages/host/api-gateway/src/index.ts +++ b/packages/host/api-gateway/src/index.ts @@ -12,7 +12,6 @@ import { type InvocationParameterDescriptor, type TypeRTCodec, type TypeRTGatewayBinding, - type TypeRTLookupProvider, } from '@deepseek-ai/dsh-type-meta' import type { InvokeRemoteRequest, @@ -149,6 +148,7 @@ export class TypertGatewayService extends Service implements TypertGateway { payload: unknown, _signal: AbortSignal, ): Promise { + // Remote methods have no cancellation parameter yet, so disconnects do not cancel business work. return this.invokeRpc(endpoint, payload) } @@ -229,10 +229,8 @@ export class TypertGatewayService extends Service implements TypertGateway { const parameters: InvocationParameterDescriptor[] = [] const wires = new Set() for (const name of names) { - const matches = this.ctx.typert.lookups.keys() - .map(key => ({ key, provider: this.ctx.typert.lookups.get(key) })) - .filter((entry): entry is { key: string; provider: TypeRTLookupProvider } => - entry.provider?.parameter === name) + const matches = this.ctx.typert.lookups.definitions() + .filter(definition => definition.parameter === name) if (matches.length > 1) { throw new TypertGatewayError( 'signature-invalid', @@ -246,7 +244,7 @@ export class TypertGatewayService extends Service implements TypertGateway { ? { name, wire: name, source: 'json', codec: { mode: 'src-json' } } : { name, - wire: match.provider.wire, + wire: match.wire, source: 'lookup', lookup: match.key, codec: { mode: 'src-json' }, @@ -540,7 +538,7 @@ function decode( field: string, ): unknown { try { - if (codec.mode === 'strict') return codec.schema.parse(value) + if (codec.mode === 'strict') value = codec.schema.parse(value) assertJsonValue(value, new Set()) return value } catch (cause) { diff --git a/packages/host/api-gateway/tests/client.spec.ts b/packages/host/api-gateway/tests/client.spec.ts index ab08ef09bc..2e00d29c0d 100644 --- a/packages/host/api-gateway/tests/client.spec.ts +++ b/packages/host/api-gateway/tests/client.spec.ts @@ -204,7 +204,13 @@ describe('Client TypeRT API', () => { }) it('rejects duplicate, live, scoped-service, and Context namespace collisions', async () => { - const ctx = await bench(vi.fn()) + const call = vi.fn() + .mockResolvedValue({ ok: true, value: { renamed: true } }) + const ctx = await bench(call) + const agentCtx = ctx.extend({ fixtureId: 'agent-remounted' }) as FixtureContext + ctx.typert.contexts.registerClient('fixture', { + identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId, + }) const direct = directDescriptor() const context = contextDescriptor() @@ -242,6 +248,13 @@ describe('Client TypeRT API', () => { package: '@fixture/multiple-scoped', descriptors: [directDescriptor(), contextDescriptor()], }) + await expect(agentCtx.goals.rename({ objective: 'remounted' })).resolves.toEqual({ renamed: true }) + expect(call).toHaveBeenLastCalledWith( + '/api', + 'goals/rename', + { args: { agentId: 'agent-remounted', request: { objective: 'remounted' } } }, + expect.any(AbortSignal), + ) await disposeMultipleScoped() }) diff --git a/packages/host/api-gateway/tests/gateway.spec.ts b/packages/host/api-gateway/tests/gateway.spec.ts index d5a3f9a8ee..4aeadeedb8 100644 --- a/packages/host/api-gateway/tests/gateway.spec.ts +++ b/packages/host/api-gateway/tests/gateway.spec.ts @@ -370,6 +370,19 @@ describe('TypertGatewayService', () => { })).resolves.toEqual({ agentId: 'agent-1', title: 'ship', scope: 'direct-src' }) }) + it('does not downgrade an observed SRC lookup after its provider unloads', async () => { + const { ctx, service } = await setup() + const dispose = registerAgentLookup(ctx, { id: 'agent-1' }) + await dispose() + + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: { agentId: 'agent-1', request: { title: 'ship' } }, + }), 'lookup-unavailable') + expect(service.calls).toEqual([]) + }) + it('derives SRC Remote Context identity and preserves the scoped Proxy receiver', async () => { const { ctx } = await setup() const scoped = ctx.extend({ fixtureScope: 'agent-src' }) @@ -657,6 +670,22 @@ describe('TypertGatewayService', () => { }), 'result-invalid') }) + it('rejects non-JSON values after strict codec validation', async () => { + const { ctx, service } = await setup() + const descriptor = strictOnlyDescriptor() + registerStrict(ctx, [{ + ...descriptor, + result: strictCodec('@fixture/gateway#UnknownResult', z.unknown()), + }]) + service.nextResult = 1n + + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'strictOnly', + args: { request: { title: 'ship' } }, + }), 'result-invalid') + }) + it.each([ undefined, Number.NaN, diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index 5757d7cef5..f430d757fb 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -1318,6 +1318,8 @@ class FaceAnalyzer { * type evaluator. */ private resolvedRemoteCodecType(authoredType: ts.TypeNode): TypeNodeId { + const resolvedType = this.checker.getTypeFromTypeNode(authoredType) + this.assertRemoteJsonType(resolvedType, authoredType, new Set(), false) const completed = new Map() const active = new Map() const recursiveDeclarations = new Map() @@ -1474,7 +1476,107 @@ class FaceAnalyzer { active.delete(type) } } - return convert(this.checker.getTypeFromTypeNode(authoredType)) + return convert(resolvedType) + } + + private assertRemoteJsonType( + type: ts.Type, + site: ts.TypeNode, + active: Set, + allowUndefined: boolean, + ): void { + const flags = type.flags + if ((flags & ts.TypeFlags.Undefined) !== 0 && allowUndefined) return + if ((flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0) { + this.fail(site, `Remote boundary contains unconstrained ${this.checker.typeToString(type)} data`) + } + if ((flags & (ts.TypeFlags.BigIntLike | ts.TypeFlags.ESSymbolLike | ts.TypeFlags.Undefined | ts.TypeFlags.Void)) !== 0) { + this.fail(site, `Remote boundary contains non-JSON type ${this.checker.typeToString(type)}`) + } + if ((flags & (ts.TypeFlags.StringLike + | ts.TypeFlags.NumberLike + | ts.TypeFlags.BooleanLike + | ts.TypeFlags.Null + | ts.TypeFlags.Never)) !== 0) return + if (type.isUnion()) { + for (const member of type.types) this.assertRemoteJsonType(member, site, active, allowUndefined) + return + } + if (type.isIntersection()) { + const material = type.types.filter(member => !this.isRemotePhantomConstraint(member)) + if (material.length === 0) this.fail(site, 'Remote boundary contains a symbol-only object') + for (const member of material) this.assertRemoteJsonType(member, site, active, false) + return + } + if ((flags & ts.TypeFlags.TypeParameter) !== 0) { + this.fail(site, 'Remote boundary contains an unresolved type parameter') + } + if ((flags & ts.TypeFlags.Object) === 0) { + this.fail(site, `Remote boundary contains non-JSON type ${this.checker.typeToString(type)}`) + } + const symbol = type.getSymbol() + const declaration = symbol?.valueDeclaration ?? symbol?.declarations?.[0] + if (declaration !== undefined && (ts.isClassDeclaration(declaration) || ts.isClassExpression(declaration))) { + this.fail(site, `Remote boundary contains class instance ${symbol?.name ?? this.checker.typeToString(type)}`) + } + if (type.getCallSignatures().length > 0 || type.getConstructSignatures().length > 0) { + this.fail(site, 'Remote boundary contains callable or constructable data') + } + if (active.has(type)) return + active.add(type) + try { + if (this.checker.isTupleType(type)) { + const reference = type as ts.TypeReference + const target = reference.target as ts.TupleType + const arguments_ = this.checker.getTypeArguments(reference) + arguments_.forEach((argument, index) => { + const elementFlags = target.elementFlags[index] ?? ts.ElementFlags.Required + this.assertRemoteJsonType( + argument, + site, + active, + (elementFlags & ts.ElementFlags.Optional) !== 0, + ) + }) + return + } + if (this.checker.isArrayType(type) || this.checker.isArrayLikeType(type)) { + const element = this.checker.getIndexTypeOfType(type, ts.IndexKind.Number) + if (element === undefined) this.fail(site, 'Remote boundary array has no element type') + this.assertRemoteJsonType(element, site, active, false) + return + } + const properties = this.checker.getPropertiesOfType(type) + if (properties.some(property => property.getName().startsWith('__@'))) { + this.fail(site, 'Remote boundary contains a symbol-keyed property') + } + for (const property of properties) { + const propertyDeclaration = property.valueDeclaration ?? property.declarations?.[0] + const propertyType = this.checker.getTypeOfSymbolAtLocation(property, propertyDeclaration ?? site) + this.assertRemoteJsonType( + propertyType, + site, + active, + (property.flags & ts.SymbolFlags.Optional) !== 0, + ) + } + for (const info of this.checker.getIndexInfosOfType(type)) { + if ((info.keyType.flags & ts.TypeFlags.ESSymbolLike) !== 0) { + this.fail(site, 'Remote boundary contains a symbol index signature') + } + this.assertRemoteJsonType(info.type, site, active, false) + } + } finally { + active.delete(type) + } + } + + private isRemotePhantomConstraint(type: ts.Type): boolean { + if ((type.flags & ts.TypeFlags.Unknown) !== 0) return true + if ((type.flags & ts.TypeFlags.Any) !== 0 || (type.flags & ts.TypeFlags.Object) === 0) return false + if (type.getCallSignatures().length > 0 || type.getConstructSignatures().length > 0) return false + if (this.checker.getIndexInfosOfType(type).length > 0) return false + return this.checker.getPropertiesOfType(type).every(property => property.getName().startsWith('__@')) } private resolvedCycleReference( diff --git a/packages/typert/generator/tests/remote-model.spec.ts b/packages/typert/generator/tests/remote-model.spec.ts index 90056e673e..cb6e6e6060 100644 --- a/packages/typert/generator/tests/remote-model.spec.ts +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -284,6 +284,32 @@ export type GenericResult = { expect(() => analyzeRemote(root, false)).toThrow(/non-JSON class parameter Agent requires a TypeRTLookupMap entry/) }) + it.each([ + ['bigint', 'bigint'], + ['symbol', 'symbol'], + ['undefined', 'undefined'], + ['any', 'unconstrained any'], + ['unknown', 'unconstrained unknown'], + ])('rejects non-JSON Remote boundary type %s', (type, message) => { + const root = copyFixture() + editFile(root, 'packages/remote/src/types.ts', source => source.replace( + ' readonly title: string\n}', + ` readonly title: string\n readonly invalid: ${type}\n}`, + )) + + expect(() => analyzeRemote(root, false)).toThrow(new RegExp(message)) + }) + + it('keeps optional JSON object fields valid', () => { + const root = copyFixture() + editFile(root, 'packages/remote/src/types.ts', source => source.replace( + ' readonly title: string\n}', + ' readonly title: string\n readonly note?: string\n}', + )) + + expect(() => analyzeRemote(root)).not.toThrow() + }) + it('rejects a Remote Context without a static Context declaration', () => { const root = copyFixture() editFile(root, 'packages/remote/src/index.ts', source => source.replace("@RemoteContext('agent')", "@RemoteContext('missing')")) diff --git a/packages/typert/loader/src/index.ts b/packages/typert/loader/src/index.ts index fee1098340..efe0fa6f94 100644 --- a/packages/typert/loader/src/index.ts +++ b/packages/typert/loader/src/index.ts @@ -135,10 +135,8 @@ export function validateTypertManifest(pkgName: string, exported: unknown): Type requireMembers(pkgName, object.members, `object "${object.name as string}"`) requireTypes(pkgName, object.types, `object "${object.name as string}"`) } - if (manifest.invocations !== undefined) { - for (const value of requireArray(pkgName, manifest.invocations, 'TYPERT.invocations')) { - requireInvocation(pkgName, value) - } + for (const value of requireArray(pkgName, manifest.invocations, 'TYPERT.invocations')) { + requireInvocation(pkgName, value) } return manifest as unknown as TypertContribution } diff --git a/packages/typert/loader/tests/loader.spec.ts b/packages/typert/loader/tests/loader.spec.ts index 1e7e553605..ec407f82d9 100644 --- a/packages/typert/loader/tests/loader.spec.ts +++ b/packages/typert/loader/tests/loader.spec.ts @@ -60,6 +60,7 @@ function typertSource(pkgName: string, entryName: string): string { ' face: \'host\',', ` schemas: [{ name: '${entryName}', schema: ${entryName} }],`, ' model: { services: [], events: [], objects: [] },', + ' invocations: [],', '}', '', ].join('\n') @@ -262,6 +263,7 @@ describe('typert loader', () => { ' face: \'host\',', ' schemas: [{ name: \'Pending\', schema: Pending }],', ' model: { services: [], events: [], objects: [] },', + ' invocations: [],', '}', '', ].join('\n'), @@ -295,7 +297,7 @@ describe('typert loader', () => { root = await mkdtemp(join(tmpdir(), 'dsh-typert-loader-')) await linkZod(root) await writePackage(root, '@fixture/broken', { - typertSource: 'export const TYPERT = { package: \'@fixture/broken\', face: \'host\', schemas: [{ name: \'\', schema: {} }], model: { services: [], events: [], objects: [] } }\n', + typertSource: 'export const TYPERT = { package: \'@fixture/broken\', face: \'host\', schemas: [{ name: \'\', schema: {} }], model: { services: [], events: [], objects: [] }, invocations: [] }\n', }) const ctx = await boot() await ctx.loader.create({ name: '@fixture/broken' }) @@ -410,6 +412,7 @@ describe('validateTypertManifest', () => { face: 'host', schemas: [{ name: 'A', schema: zodish }], model: { services: [], events: [], objects: [] }, + invocations: [], }).schemas).toHaveLength(1) expect(() => validateTypertManifest('pkg', undefined)).toThrow('no TYPERT manifest object') @@ -490,12 +493,14 @@ describe('validateTypertManifest', () => { })).toThrow('object has a missing or empty exportName') }) - it('validates strict invocation descriptors and accepts legacy manifests without them', () => { - const legacy = completeManifest(zodish) - expect(validateTypertManifest('pkg', legacy)).toBe(legacy) + it('requires and validates strict invocation descriptors', () => { + const base = completeManifest(zodish) + const { invocations: _invocations, ...missingInvocations } = base + expect(() => validateTypertManifest('pkg', missingInvocations)) + .toThrow('TYPERT.invocations must be an array') const descriptor = strictInvocation() - const manifest = { ...legacy, invocations: [descriptor] } + const manifest = { ...base, invocations: [descriptor] } expect(validateTypertManifest('pkg', manifest)).toBe(manifest) const scoped = { ...descriptor, @@ -508,53 +513,53 @@ describe('validateTypertManifest', () => { codec: strictCodec('pkg#AgentId'), }, ...descriptor.parameters], } - expect(validateTypertManifest('pkg', { ...legacy, invocations: [scoped] }).invocations) + expect(validateTypertManifest('pkg', { ...base, invocations: [scoped] }).invocations) .toEqual([scoped]) - expect(() => validateTypertManifest('pkg', { ...legacy, invocations: {} })) + expect(() => validateTypertManifest('pkg', { ...base, invocations: {} })) .toThrow('TYPERT.invocations must be an array') expect(() => validateTypertManifest('pkg', { - ...legacy, + ...base, invocations: [{ ...descriptor, invocation: { kind: 'future' } }], })).toThrow('receiver kind must be "direct" or "context"') expect(() => validateTypertManifest('pkg', { - ...legacy, + ...base, invocations: [{ ...descriptor, result: { mode: 'src-json' } }], })).toThrow('result codec must use a strict codec') expect(() => validateTypertManifest('pkg', { - ...legacy, + ...base, invocations: [{ ...descriptor, result: { mode: 'strict', typeSymbol: 'pkg#Result', schema: zodish } }], })).toThrow('result codec is not backed by a zod v4 schema') expect(() => validateTypertManifest('pkg', { - ...legacy, + ...base, invocations: [{ ...descriptor, parameters: [{ ...descriptor.parameters[0], source: 'future' }], }], })).toThrow('parameter source must be "json" or "lookup"') expect(() => validateTypertManifest('pkg', { - ...legacy, + ...base, invocations: [{ ...descriptor, parameters: [{ ...descriptor.parameters[0], source: 'lookup' }], }], })).toThrow('lookup parameter has a missing or empty lookup') expect(() => validateTypertManifest('pkg', { - ...legacy, + ...base, invocations: [{ ...descriptor, parameters: [{ ...descriptor.parameters[0], lookup: 'agent' }], }], })).toThrow('JSON parameter declares a lookup') expect(() => validateTypertManifest('pkg', { - ...legacy, + ...base, invocations: [{ ...descriptor, parameters: [descriptor.parameters[0], { ...descriptor.parameters[0], name: 'again' }], }], })).toThrow('repeats wire field "request"') expect(() => validateTypertManifest('pkg', { - ...legacy, + ...base, invocations: [{ ...descriptor, invocation: { @@ -566,19 +571,19 @@ describe('validateTypertManifest', () => { }], })).toThrow('repeats Context wire field "request"') expect(() => validateTypertManifest('pkg', { - ...legacy, + ...base, invocations: [{ ...scoped, scope: null }], })).toThrow('scope must be an object') expect(() => validateTypertManifest('pkg', { - ...legacy, + ...base, invocations: [{ ...scoped, scope: { wire: 'agentId' } }], })).toThrow('scope has a missing or empty context') expect(() => validateTypertManifest('pkg', { - ...legacy, + ...base, invocations: [{ ...scoped, scope: { context: 'agent' } }], })).toThrow('scope has a missing or empty wire') expect(() => validateTypertManifest('pkg', { - ...legacy, + ...base, invocations: [{ ...scoped, invocation: { @@ -590,11 +595,11 @@ describe('validateTypertManifest', () => { }], })).toThrow('Context receiver cannot declare a direct scope projection') expect(() => validateTypertManifest('pkg', { - ...legacy, + ...base, invocations: [{ ...scoped, scope: { context: 'agent', wire: 'missingId' } }], })).toThrow('must select its only lookup parameter') expect(() => validateTypertManifest('pkg', { - ...legacy, + ...base, invocations: [{ ...scoped, parameters: [...scoped.parameters, { @@ -607,11 +612,11 @@ describe('validateTypertManifest', () => { }], })).toThrow('must select its only lookup parameter') expect(() => validateTypertManifest('pkg', { - ...legacy, + ...base, invocations: [{ ...scoped, scope: { context: 'other', wire: 'agentId' } }], })).toThrow('must select its only lookup parameter') expect(() => validateTypertManifest('pkg', { - ...legacy, + ...base, invocations: [{ ...descriptor, sourceLocation: { file: 'src/index.ts', line: 0, column: 1 } }], })).toThrow('sourceLocation.line must be a positive integer') }) @@ -646,6 +651,7 @@ function completeManifest(zodish: object) { package: 'pkg', face: 'host', schemas: [{ name: 'Schema', schema: zodish }], + invocations: [], model: { services: [{ key: 'service', diff --git a/packages/typert/registry/src/service.ts b/packages/typert/registry/src/service.ts index 4973732fad..6749cdbeb9 100644 --- a/packages/typert/registry/src/service.ts +++ b/packages/typert/registry/src/service.ts @@ -17,6 +17,7 @@ import type { TypeRTHostContextProvider, TypeRTLocalRegistry, TypeRTLookupHost, + TypeRTLookupDefinition, TypeRTLookupMap, TypeRTLookupProvider, TypeRTLookupRegistry, @@ -212,6 +213,7 @@ class RemoteStore { class LookupStore { private readonly providers = new Map>() + private readonly definitions = new Map() private readonly changes: ChangeSource constructor(report: ReportObserverError) { @@ -228,6 +230,7 @@ class LookupStore { >, ) => this.register(ctx, key, provider), get: key => this.providers.get(key)?.provider, + definitions: () => [...this.definitions.values()], keys: () => [...this.providers.keys()], subscribe: listener => this.changes.subscribe(ctx, listener), } @@ -240,10 +243,22 @@ class LookupStore { validateNonempty('lookup Host type symbol', provider.hostTypeSymbol) validateNonempty('lookup wire type symbol', provider.wireTypeSymbol) if (this.providers.has(key)) throw new Error(`typert: lookup "${key}" is already registered`) + const definition: TypeRTLookupDefinition = { + key, + parameter: provider.parameter, + wire: provider.wire, + hostTypeSymbol: provider.hostTypeSymbol, + wireTypeSymbol: provider.wireTypeSymbol, + } + const known = this.definitions.get(key) + if (known !== undefined && !lookupDefinitionEquals(known, definition)) { + throw new Error(`typert: lookup "${key}" changed its wire declaration during this registry lifetime`) + } const owner = {} const entry: ProviderEntry = { provider, owner } - const { providers, changes } = this + const { definitions, providers, changes } = this return ctx.effect(function* () { + definitions.set(key, definition) providers.set(key, entry) changes.emit({ kind: 'lookup', key }) yield () => { @@ -256,6 +271,13 @@ class LookupStore { } } +function lookupDefinitionEquals(left: TypeRTLookupDefinition, right: TypeRTLookupDefinition): boolean { + return left.parameter === right.parameter + && left.wire === right.wire + && left.hostTypeSymbol === right.hostTypeSymbol + && left.wireTypeSymbol === right.wireTypeSymbol +} + class ContextStore { private readonly hosts = new Map>() private readonly clients = new Map>() @@ -377,7 +399,7 @@ export class TypertRegistry extends Service implements TypeRTService { register(contribution: TypertContribution): TypeRTDisposer { const packageRecord = this.validatePackage(contribution) const schemaRecords = this.validateSchemas(contribution) - const invocations = contribution.invocations ?? [] + const invocations = contribution.invocations this.localStore.validate(invocations) const owner = {} const { schemas, packages, localStore } = this diff --git a/packages/typert/registry/src/types.ts b/packages/typert/registry/src/types.ts index 6ba0e0f1f2..4dcfc4b7a1 100644 --- a/packages/typert/registry/src/types.ts +++ b/packages/typert/registry/src/types.ts @@ -83,12 +83,7 @@ export interface TypertContribution { readonly face: TypertFace readonly schemas: readonly TypertSchema[] readonly model: TypertPackageModel - /** Host invocation definitions; absent on artifacts generated before Remote support. */ - readonly invocations?: readonly InvocationDescriptor[] -} - -/** Generated Host contribution with strict Remote invocation definitions. */ -export interface TypertLocalContribution extends TypertContribution { + /** Host invocation definitions, empty when the package exports no Remote methods. */ readonly invocations: readonly InvocationDescriptor[] } diff --git a/packages/typert/registry/tests/typert.spec.ts b/packages/typert/registry/tests/typert.spec.ts index 95f8bc871f..51e7594749 100644 --- a/packages/typert/registry/tests/typert.spec.ts +++ b/packages/typert/registry/tests/typert.spec.ts @@ -36,6 +36,7 @@ function toolsContribution(schema: z.ZodType = z.object({ name: z.string() })): package: '@deepseek-ai/dsh-tools', face: 'host', schemas: [{ name: 'ToolInput', schema }], + invocations: [], model: { services: [{ key: 'tools', @@ -329,11 +330,19 @@ describe('TypertRegistry', () => { }) expect(ctx.typert.lookups.get('fixture')?.resolve('agent-1')).toBe(object) + expect(ctx.typert.lookups.definitions()).toEqual([{ + key: 'fixture', + parameter: 'agent', + wire: 'agentId', + hostTypeSymbol: '@fixture/agent#Agent', + wireTypeSymbol: '@fixture/session#SessionId', + }]) expect(ctx.typert.contexts.getHost('registryFixture')?.resolve('agent-1')).toBe(scoped) expect(ctx.typert.contexts.getClient('registryFixture')?.identity(scoped)).toBe('agent-1') await Promise.all([disposeClient(), disposeHost(), disposeLookup()]) expect(ctx.typert.lookups.keys()).toEqual([]) + expect(ctx.typert.lookups.definitions()).toHaveLength(1) expect(ctx.typert.contexts.getHost('registryFixture')).toBeUndefined() expect(ctx.typert.contexts.getClient('registryFixture')).toBeUndefined() }) @@ -378,6 +387,15 @@ describe('TypertRegistry', () => { ]) await Promise.all([disposeLookupSubscription(), disposeContextSubscription()]) + for (const changed of [ + { ...lookup, parameter: 'session' }, + { ...lookup, wire: 'sessionId' }, + { ...lookup, hostTypeSymbol: '@fixture#Session' }, + { ...lookup, wireTypeSymbol: '@fixture#SessionId' }, + ]) { + expect(() => ctx.typert.lookups.register('fixture', changed)) + .toThrow('changed its wire declaration during this registry lifetime') + } ctx.typert.lookups.register('fixture', lookup) expect(changes).toHaveLength(6) }) diff --git a/packages/typert/type-meta/src/index.ts b/packages/typert/type-meta/src/index.ts index 1e79bb2e55..92438ee0fa 100644 --- a/packages/typert/type-meta/src/index.ts +++ b/packages/typert/type-meta/src/index.ts @@ -20,6 +20,7 @@ export type { TypeRTHostContextProvider, TypeRTLocalRegistry, TypeRTLookup, + TypeRTLookupDefinition, TypeRTLookupHost, TypeRTLookupMap, TypeRTLookupProvider, diff --git a/packages/typert/type-meta/src/types.ts b/packages/typert/type-meta/src/types.ts index 87ab091075..f9ed7ffa97 100644 --- a/packages/typert/type-meta/src/types.ts +++ b/packages/typert/type-meta/src/types.ts @@ -189,6 +189,20 @@ export interface TypeRTLookupProvider { resolve(id: Wire): Host | undefined } +/** Stable wire declaration retained after a lookup provider unloads. */ +export interface TypeRTLookupDefinition { + /** Merge-declared lookup key. */ + readonly key: string + /** Source parameter name recognized by the SRC weak parser. */ + readonly parameter: string + /** Wire field replacing the Host object parameter. */ + readonly wire: string + /** Canonical Host type symbol used by strict generation. */ + readonly hostTypeSymbol: string + /** Canonical wire type symbol used by strict generation. */ + readonly wireTypeSymbol: string +} + /** Host resolver for one scoped Remote Context kind. */ export interface TypeRTHostContextProvider { /** Wire field carrying the Context identity. */ @@ -291,6 +305,8 @@ export interface TypeRTLookupRegistry { * @returns the live provider, or `undefined` when absent. */ get(key: string): TypeRTLookupProvider | undefined + /** @returns lookup declarations observed during this TypeRT Service lifetime. */ + definitions(): readonly TypeRTLookupDefinition[] /** @returns a snapshot of registered provider keys. */ keys(): readonly string[] /** diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 84b957e633..edadc3f134 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1494,6 +1494,66 @@ "doc": "docs/core-data-structures/settings.md", "symbol": "SettingsPathOp", "source": "packages/settings/settings/src/index.ts" + }, + { + "doc": "docs/core-data-structures/typert.md", + "symbol": "TypeRTLookupMap", + "source": "packages/typert/type-meta/src/types.ts" + }, + { + "doc": "docs/core-data-structures/typert.md", + "symbol": "TypeRTContextMap", + "source": "packages/typert/type-meta/src/types.ts" + }, + { + "doc": "docs/core-data-structures/typert.md", + "symbol": "TypeRTLookupDefinition", + "source": "packages/typert/type-meta/src/types.ts" + }, + { + "doc": "docs/core-data-structures/typert.md", + "symbol": "TypeRTCodec", + "source": "packages/typert/type-meta/src/types.ts" + }, + { + "doc": "docs/core-data-structures/typert.md", + "symbol": "InvocationParameterDescriptor", + "source": "packages/typert/type-meta/src/types.ts" + }, + { + "doc": "docs/core-data-structures/typert.md", + "symbol": "InvocationDescriptor", + "source": "packages/typert/type-meta/src/types.ts" + }, + { + "doc": "docs/core-data-structures/typert.md", + "symbol": "TypeRTService", + "source": "packages/typert/type-meta/src/types.ts" + }, + { + "doc": "docs/core-data-structures/typert.md", + "symbol": "TypeRTRemoteNamespaceMap", + "source": "packages/typert/type-meta/src/types.ts" + }, + { + "doc": "docs/core-data-structures/typert.md", + "symbol": "InvokeRemoteRequest", + "source": "packages/host/api-gateway/src/types.ts" + }, + { + "doc": "docs/core-data-structures/typert.md", + "symbol": "TypertGatewayErrorCode", + "source": "packages/host/api-gateway/src/types.ts" + }, + { + "doc": "docs/core-data-structures/typert.md", + "symbol": "TypertGateway", + "source": "packages/host/api-gateway/src/types.ts" + }, + { + "doc": "docs/core-data-structures/typert.md", + "symbol": "ClientApi", + "source": "packages/host/api-gateway/src/client/index.ts" } ] } From 22bec5e63f1656a7c0c3a931a8293f1fb4a223a5 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:13:15 +0800 Subject: [PATCH 068/104] feat(typert): propagate Remote cancellation --- ...08-02-typert-remote-method-calls.i18n.yaml | 4 +- .../2026-08-02-typert-remote-method-calls.md | 37 ++++++++++------ ...026-08-02-typert-remote-method-calls.zh.md | 37 ++++++++++------ docs/core-data-structures/typert.i18n.yaml | 4 +- docs/core-data-structures/typert.md | 11 ++++- docs/core-data-structures/typert.zh.md | 11 ++++- .../cordis/tool-cordis/src/api-catalog.ts | 4 +- packages/host/api-gateway/README.i18n.yaml | 4 +- packages/host/api-gateway/README.md | 4 +- packages/host/api-gateway/README.zh.md | 4 +- packages/host/api-gateway/src/client/index.ts | 14 ++++-- packages/host/api-gateway/src/index.ts | 26 ++++++++--- packages/host/api-gateway/src/types.ts | 2 + .../host/api-gateway/tests/client.spec.ts | 37 ++++++++++++++-- .../host/api-gateway/tests/gateway.spec.ts | 43 +++++++++++++++++-- packages/typert/generator/src/analyzer.ts | 23 +++++++++- packages/typert/generator/src/emitter.ts | 4 ++ packages/typert/generator/src/model.ts | 3 ++ .../remote-model/packages/remote/src/index.ts | 3 +- .../generator/tests/remote-model.spec.ts | 31 +++++++++++-- packages/typert/loader/src/index.ts | 6 +++ packages/typert/loader/tests/loader.spec.ts | 13 ++++++ packages/typert/registry/src/service.ts | 3 ++ packages/typert/registry/tests/typert.spec.ts | 5 +++ packages/typert/type-meta/README.i18n.yaml | 4 +- packages/typert/type-meta/README.md | 2 + packages/typert/type-meta/README.zh.md | 2 + packages/typert/type-meta/src/types.ts | 5 +++ 28 files changed, 280 insertions(+), 66 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index 752a5d4c8b..bd83c38a3e 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: 91ab8e44ff8aedf666fe3426b85b54491deb340c -2026-08-02-typert-remote-method-calls.zh.md: 73abd53109d871076aa41af39825c80c35ac3f26 +2026-08-02-typert-remote-method-calls.md: 4268539ecf0d40a9e8080e0571992cc2c5d724af +2026-08-02-typert-remote-method-calls.zh.md: f9f426f2fb80c74cb9ebaef15e801ccfcf67e027 diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index 91ab8e44ff..4268539ecf 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -76,6 +76,8 @@ An endpoint selects exactly one invocation mode. A flow that needs an explicit ` Business packages depend only on the lightweight `@deepseek-ai/dsh-type-meta`. It provides declaration protocols for decorators, `bindTypeRTGateway()`, lookup, Remote Context, and descriptors, without depending on the TypeScript compiler, Zod, HTTP, or the Client runtime. +A method that cooperatively supports cancellation declares `signal: AbortSignal` as its final Host parameter. This reserved parameter is not a business value, lookup, or JSON field. The generated consumer method exposes it as a final optional parameter so ordinary calls remain unchanged while callers that own cancellation can pass a signal. + ## Decorators and the explicit Gateway facet A decorator only states that a method participates in the Remote contract. It performs no runtime type reflection and injects no hidden symbol into a Service constructor. The arguments to `@Remote('create')` and `@RemoteContext('agent', 'create')` are external method names, while the actual member remains named `remoteExportCreate`. The member name becomes the external method name only when no alias is provided. `typertGateway` is the sole explicit marker that a Service has joined the Gateway, making this capability visible on both the business class and its runtime instance. @@ -126,6 +128,7 @@ InvocationDescriptor { parameters: [ { name, wire, source: json | lookup, lookup?, codec } ] + cancellation?: { parameter: 'signal' } result: codec sourceLocation } @@ -135,7 +138,7 @@ InvocationDescriptor { The strict generator writes `scope` only when a direct method has exactly one lookup parameter, a `TypeRTContextMap` declaration with the same name exists, and both use the same wire type symbol. `scope.wire` must identify that lookup parameter. It declares that a consumer may fill this parameter from the Context in which the call occurs, without changing the Host receiver or endpoint. No scoped projection is generated when there are multiple lookups, no Context declaration, or mismatched wire types; a type mismatch is a build error. -Parameter order comes from the method signature. HTTP fields come from parameter names or lookup declarations. The Gateway does not infer optional fields, Context types, lookup types, or missing arguments from request contents, and it does not synthesize business defaults. +Parameter order comes from the method signature. HTTP fields come from parameter names or lookup declarations. A cancellation descriptor reserves only the final `signal` position and keeps it outside named `args`; Connection or a direct Gateway caller supplies the actual signal. The Gateway does not infer optional fields, Context types, lookup types, or missing arguments from request contents, and it does not synthesize business defaults. A LIB codec contains a Zod schema and a canonical `typeSymbol` consisting of "package + public subpath + export name." An SRC codec is marked only as `src-json`. When the Host and consumer run in different JavaScript realms, each holds its own Zod instances, but both sets are generated from the same TypeRT model and symbol keys. @@ -239,6 +242,7 @@ interface TypeRTRemoteNamespace$676f616c73 { create: ( agentId: SessionId, request: CreateGoalRequest, + signal?: AbortSignal, ) => Promise } @@ -246,6 +250,7 @@ interface TypeRTRemoteMap { 'goals/create': ( agentId: SessionId, request: CreateGoalRequest, + signal?: AbortSignal, ) => Promise } @@ -256,6 +261,7 @@ interface TypeRTRemoteNamespaceMap { interface TypeRTRemoteContextMap { 'agent:goals/create': ( request: CreateGoalRequest, + signal?: AbortSignal, ) => Promise } ``` @@ -296,7 +302,7 @@ Client business packages depend only on `@deepseek-ai/dsh-client-remotes/client` `ctx.api.mount()` registers a contribution with `TypeRT.remotes`, and its disposer is owned by the Cordis fiber that called the method. Duplicate endpoints, conflicting invocation modes for the same namespace and method, or conflicts between a descriptor and an existing type identity fail immediately. -The API Service materializes each `@Remote` descriptor as a real function on the root `api`. The function constructs named `args` in descriptor parameter order, applies the Client's strict codec, and then calls `ctx.connection.rpc.call('/api', endpoint, { args })`. +The API Service materializes each `@Remote` descriptor as a real function on the root `api`. The function constructs named `args` in descriptor parameter order, applies the Client's strict codec, and then calls `ctx.connection.rpc.call('/api', endpoint, { args }, signal)`. For a cancellation-aware descriptor, the generated function accepts a final optional signal and combines it with the contribution mount lifetime; unmounting therefore cancels every in-flight carrier call, while a caller can cancel one call independently. Neither a direct descriptor with `scope` nor a `@RemoteContext` descriptor copies functions into every Agent Scope. The API Service creates one root singleton Cordis Service for each scoped namespace and materializes methods on that Service. When `agent.goals.create()` is called, the Cordis tracker rebinds the Service's `this.ctx` to the current Agent Context. The method then asks the corresponding Context binder for identity from `this.ctx`. A direct scoped projection substitutes this identity at the lookup position named by `scope.wire`; a Context descriptor writes the identity into the receiver's separate wire field. Both issue the same kind of `/api` call. @@ -332,11 +338,11 @@ The Web already depends on build artifacts such as `lib/client.js`, so it requir SRC supports local source startup. The `WeakMap` records created by `@Remote` and `@RemoteContext()` provide method names and invocation modes. At runtime, the system reads ordered parameter names from the JavaScript function signature and combines them with registered lookup/Context providers to produce a permissive descriptor. -For example, `@Remote('create') remoteExportCreate(agent, request)` resolves to the external method `create`, implementation member `remoteExportCreate`, and two top-level parameters. Lookup registration rewrites `agent` to the wire field `agentId`, while `request` is passed as a same-named JSON parameter. SRC does not start a `ts.Program`, use a preload or loader hook, generate or rewrite source, or inspect the internal structure of an ordinary JSON object. +For example, `@Remote('create') remoteExportCreate(agent, request, signal)` resolves to the external method `create`, implementation member `remoteExportCreate`, two top-level business parameters, and one cancellation injection point. Lookup registration rewrites `agent` to the wire field `agentId`, `request` is passed as a same-named JSON parameter, and the final `signal` stays outside the payload. SRC does not start a `ts.Program`, use a preload or loader hook, generate or rewrite source, or inspect the internal structure of an ordinary JSON object. A signature that SRC cannot resolve unambiguously fails when the Service mounts. It does not guess at object destructuring, ambiguity caused by default parameters, rest parameters, nested lookups, or complex types. -LIB supports CI, releases, and the prerequisite Web build. TypeRT scans the complete Host project and checks Remote decorators, explicit bindings, service keys, endpoint conflicts, lookup/Context declarations, public-symbol reachability, JSON codecs, and result codecs, then generates strict descriptors. +LIB supports CI, releases, and the prerequisite Web build. TypeRT scans the complete Host project and checks Remote decorators, explicit bindings, service keys, endpoint conflicts, lookup/Context declarations, public-symbol reachability, JSON codecs, result codecs, and that a reserved final `signal` parameter has the global `AbortSignal` type, then generates strict descriptors. At runtime, LIB only loads definitions from `lib`; it does not start the TypeScript compiler. The subsequent association of Services, lookup, Context resolution, invocation, and response encoding in the Host Gateway does not depend on whether a descriptor came from permissive SRC parsing or strict LIB generation. @@ -348,17 +354,18 @@ The Host Gateway registers one `/api` interceptor with Connection and does not m Invocation resolves the descriptor, receiver, lookup providers, and Context provider again from current state. A current strict descriptor takes precedence over SRC. After a strict endpoint has appeared, `TypeRTLocalRegistry.hasSeen()` keeps it owned when that descriptor is withdrawn and forbids SRC fallback for the remainder of the registry lifetime; re-registering the strict descriptor restores calls. Removing a Service or provider makes invocation fail explicitly, and the Gateway neither retains invalid objects nor invokes a method with a raw lookup ID. -An ordinary `@Remote` call retains the original Service instance as receiver. After lookups succeed, the Gateway calls the member identified by `implementation ?? method` with parameters in descriptor order. +An ordinary `@Remote` call retains the original Service instance as receiver. After lookups succeed, the Gateway calls the member identified by `implementation ?? method` with parameters in descriptor order, followed by the carrier signal when the descriptor declares cancellation. A `@RemoteContext('agent')` call first asks the Agent Context provider to resolve the wire identity, then reads the descriptor's service key from that Context and invokes the scoped receiver. The business method receives neither a hidden Context parameter nor an Agent ID. ```text -ctx.typertGateway.invoke({ namespace, method, args }) +ctx.typertGateway.invoke({ namespace, method, args, signal }) → 查找本地 InvocationDescriptor 与 live receiver → 按参数 descriptor 读取具名 wire 字段 → codec 解码普通值或 lookup ID → lookup provider 把 ID 解析为活对象 → direct 使用原 Service;context 先解析 scoped Context 和 Service +→ cancellation descriptor 存在时把 signal 追加到业务参数末尾 → Reflect.apply(receiver[implementation ?? method], receiver, orderedArgs) → result codec 编码业务结果 ``` @@ -373,10 +380,10 @@ Connection owns one `/api` route on the HTTP Server. The Gateway mounts a synchr ctx.connection.rpc.intercept( '/api', endpoint => ownsRemoteEndpoint(endpoint), - (endpoint, payload) => { + (endpoint, payload, signal) => { const { namespace, method } = parseEndpoint(endpoint) const { args } = parsePayload(payload) - return ctx.typertGateway.invoke({ namespace, method, args }) + return ctx.typertGateway.invoke({ namespace, method, args, signal }) }, ) ``` @@ -405,15 +412,16 @@ The Remote payload is a named JSON object, not a positional array, and does not The complete path is: ```text -ctx.api.goals.create(sessionId, request) +ctx.api.goals.create(sessionId, request, signal?) → Client InvocationDescriptor 编码 { args: { agentId, request } } -→ ctx.connection.rpc.call('/api', 'goals/create', { args }) +→ Client 合并 caller signal 与 contribution mount lifetime +→ ctx.connection.rpc.call('/api', 'goals/create', { args }, signal) → Connection 创建 rpcId 和既有 client-request envelope → 当前 carrier 发送 POST /api/goals/create → Connection Host half 执行共享 trust,再由 bridge 创建标准 Request → 复合 FetchHandler 判断 endpoint ownership 并选择目标 FetchHandler -→ TypeRT interceptor 调用 ctx.typertGateway.invoke(...) -→ Host InvocationDescriptor 解码、lookup、receiver 解析和 Reflect.apply +→ TypeRT interceptor 调用 ctx.typertGateway.invoke(..., request.signal) +→ Host InvocationDescriptor 解码、lookup、receiver 解析并把 signal 注入 Reflect.apply → result codec 编码 → Connection 写入既有 RPC result 并回送相同 rpcId → Client result codec 验证并返回 CreateGoalResult @@ -421,7 +429,7 @@ ctx.api.goals.create(sessionId, request) Remote does not define a second-layer `{ ok, value/error }` response. Successful values and Gateway errors use the existing RPC response's `result` directly. The current adapter converts every Gateway and business-invocation failure to the existing `RpcError` envelope with `code: 'internal'`; the Gateway's structured error category remains available only in-process, while the message carries the diagnostic across Connection. -The Gateway does not handle per-method permissions, caller identity, cancellation, idempotency, or long-lived connection state. TypeRT endpoints use Connection's trusted-host policy; unclaimed endpoints retain the legacy API Proxy's trust and privileged-method policies. Connection's WebSocket migration remains separate follow-up work. +The Gateway does not handle per-method permissions, caller identity, idempotency, or long-lived connection state. It only propagates cooperative cancellation from Connection into explicitly cancellation-aware business methods. TypeRT endpoints use Connection's trusted-host policy; unclaimed endpoints retain the legacy API Proxy's trust and privileged-method policies. Connection's WebSocket migration remains separate follow-up work. ## Connection and protocol boundaries @@ -475,6 +483,7 @@ Connection supplies the shared-channel interceptor and current HTTP carrier mapp - Root and Agent-scoped calls cross the real shared `/api` carrier, resolve `agentId` to the live Agent, invoke the original Goal receiver, and return through the existing RPC envelope. - The Remote artifacts and maps contain only marked methods and no Browser dependency, preserving the same consumer boundary for a future TUI. - Lifecycle tests withdraw and remount descriptors, Services, lookups, Context providers, and Client namespaces; unavailable dependencies fail without stale calls or raw-ID fallback. +- Cancellation tests cover strict generation, SRC final-name recognition, Client signal fusion, Connection-to-Gateway propagation, and Host injection outside wire `args`. - Unclaimed endpoints continue through the existing API Proxy path with its trust, privileged-method, Permission/Approval, and Session event-stream behavior unchanged. ## Consequences @@ -499,4 +508,4 @@ Remote endpoints use Connection's `trusted-host` authority. Loopback is accepted `hasSeen()` favors strict-definition safety over SRC availability. While a strict descriptor is withdrawn, such as during HMR, the Gateway continues to claim the endpoint and reports it unavailable instead of falling back to a weak SRC descriptor. Re-registration restores it; only a TypeRT registry restart forgets the historical strict definition. -Connection supplies an `AbortSignal`, but Remote business signatures have no cancellation parameter. A client disconnect therefore does not cancel business work; cancellation remains deferred rather than being implied by the transport handler shape. +Cancellation-aware Remote signatures receive Connection's request `AbortSignal`, so an HTTP disconnect or Client-side abort reaches ongoing business work without entering the JSON protocol. Cancellation remains cooperative: methods without the reserved final parameter continue running, and a method that receives the signal must pass it to its own cancellable operations or observe it directly. diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index 73abd53109..f9f426f2fb 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -76,6 +76,8 @@ export class ScopedGoalService extends Service { 业务包只依赖轻量的 `@deepseek-ai/dsh-type-meta`。它提供 decorator、`bindTypeRTGateway()`、lookup、Remote Context 和 descriptor 的声明协议,不依赖 TypeScript compiler、Zod、HTTP 或 Client runtime。 +支持协作式取消的方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。这个保留参数不是业务值、lookup 或 JSON 字段。生成的消费方方法将其暴露为最后一个可选参数,因此普通调用保持不变,而拥有取消控制权的调用方可以传入 signal。 + ## Decorator 与显式 Gateway facet Decorator 只表达“该方法参与 Remote 契约”,不负责运行时类型反射,也不向 Service constructor 注入隐藏 symbol。`@Remote('create')` 和 `@RemoteContext('agent', 'create')` 的参数是外部方法名,实际成员名保持 `remoteExportCreate`;未给别名时才使用成员名作为外部方法名。`typertGateway` 是 Service 加入 Gateway 的唯一显式标志,使业务类和运行时实例都能直接看出这项能力。 @@ -126,6 +128,7 @@ InvocationDescriptor { parameters: [ { name, wire, source: json | lookup, lookup?, codec } ] + cancellation?: { parameter: 'signal' } result: codec sourceLocation } @@ -135,7 +138,7 @@ InvocationDescriptor { 严格生成器只在 direct 方法恰好包含一个 lookup 参数、同名 `TypeRTContextMap` 声明存在且两者使用同一 wire 类型 symbol 时写入 `scope`。`scope.wire` 必须指向该 lookup 参数;它声明消费端可以从调用所在 Context 补入这个参数,不改变 Host receiver 或 endpoint。多个 lookup、缺少 Context 声明或 wire 类型不一致时不生成 scoped 投影,其中类型不一致属于构建错误。 -参数顺序来自方法签名,HTTP 字段来自参数名或 lookup 声明。Gateway 不根据请求内容推断可选字段、Context 类型、lookup 类型或缺失参数,也不会合成业务默认值。 +参数顺序来自方法签名,HTTP 字段来自参数名或 lookup 声明。取消 descriptor 只保留最后一个 `signal` 位置,并使其不进入具名 `args`;实际 signal 由 Connection 或直接调用 Gateway 的调用方提供。Gateway 不根据请求内容推断可选字段、Context 类型、lookup 类型或缺失参数,也不会合成业务默认值。 LIB codec 带有 Zod schema 和“package + 公共 subpath + export name”的规范 `typeSymbol`;SRC codec 只标记 `src-json`。Host 和消费端运行在不同 JavaScript realm 时会各自持有 Zod 实例,但这些实例由同一 TypeRT 模型和 symbol key 生成。 @@ -239,6 +242,7 @@ interface TypeRTRemoteNamespace$676f616c73 { create: ( agentId: SessionId, request: CreateGoalRequest, + signal?: AbortSignal, ) => Promise } @@ -246,6 +250,7 @@ interface TypeRTRemoteMap { 'goals/create': ( agentId: SessionId, request: CreateGoalRequest, + signal?: AbortSignal, ) => Promise } @@ -256,6 +261,7 @@ interface TypeRTRemoteNamespaceMap { interface TypeRTRemoteContextMap { 'agent:goals/create': ( request: CreateGoalRequest, + signal?: AbortSignal, ) => Promise } ``` @@ -296,7 +302,7 @@ Client 业务包只引用 `@deepseek-ai/dsh-client-remotes/client`,不直接 `ctx.api.mount()` 把 contribution 注册到 `TypeRT.remotes`,并由调用该方法的 Cordis fiber 持有 disposer。endpoint 重复、同一 namespace/method 模式冲突或 descriptor 与现有类型身份冲突时直接失败。 -API Service 把 `@Remote` descriptor 实体化为根 `api` 上的真实函数。函数按 descriptor 的位置参数顺序构造具名 `args`,执行 Client strict codec,然后调用 `ctx.connection.rpc.call('/api', endpoint, { args })`。 +API Service 把 `@Remote` descriptor 实体化为根 `api` 上的真实函数。函数按 descriptor 的位置参数顺序构造具名 `args`,执行 Client strict codec,然后调用 `ctx.connection.rpc.call('/api', endpoint, { args }, signal)`。对于支持取消的 descriptor,生成的函数接受最后一个可选 signal,并将其与 contribution 的挂载生命周期合并;因此卸载会取消所有正在进行的 carrier 调用,而调用方也可以单独取消一次调用。 带 `scope` 的 direct descriptor 和 `@RemoteContext` descriptor 都不为每个 Agent Scope 复制函数。API Service 为每个 scoped namespace 建立一个 root singleton Cordis Service,并在该 Service 上实体化方法;Cordis tracker 在 `agent.goals.create()` 调用时把 Service 的 `this.ctx` rebind 到当前 Agent Context。方法再通过对应 Context binder 从 `this.ctx` 取得 identity。direct scoped 投影用 identity 替代 `scope.wire` 指定的 lookup 位置,Context descriptor 则把 identity 写入 receiver 的独立 wire 字段;两者都发起同一种 `/api` 调用。 @@ -332,11 +338,11 @@ Web 本身依赖 `lib/client.js` 等构建产物,因此启动 Web 前要求完 SRC 面向本地源码启动。`@Remote` 和 `@RemoteContext()` 的 WeakMap 记录给出方法名和调用模式,运行时从 JavaScript 函数签名读取顺序参数名,并结合已注册 lookup/Context provider 生成弱 descriptor。 -例如 `@Remote('create') remoteExportCreate(agent, request)` 解析为外部方法 `create`、实现成员 `remoteExportCreate` 和两个顶层参数;lookup 注册把 `agent` 改写为 wire 字段 `agentId`,`request` 按同名 JSON 参数传递。SRC 不启动 `ts.Program`,不使用 preload、loader hook、源码生成或模块改写,也不检查普通 JSON 对象的内部结构。 +例如 `@Remote('create') remoteExportCreate(agent, request, signal)` 解析为外部方法 `create`、实现成员 `remoteExportCreate`、两个顶层业务参数和一个取消注入点;lookup 注册把 `agent` 改写为 wire 字段 `agentId`,`request` 按同名 JSON 参数传递,最后一个 `signal` 则留在 payload 之外。SRC 不启动 `ts.Program`,不使用 preload、loader hook、源码生成或模块改写,也不检查普通 JSON 对象的内部结构。 SRC 无法明确解析的签名在 Service 挂载时失败。对象解构、默认参数造成的歧义、rest 参数、嵌套 lookup 和复杂类型不做猜测。 -LIB 面向 CI、发布和 Web 前置构建。TypeRT 扫描完整 Host project,检查 Remote decorator、显式 binding、service key、endpoint 冲突、lookup/Context 声明、公共符号可达性、JSON codec 和结果 codec,并生成严格 descriptor。 +LIB 面向 CI、发布和 Web 前置构建。TypeRT 扫描完整 Host project,检查 Remote decorator、显式 binding、service key、endpoint 冲突、lookup/Context 声明、公共符号可达性、JSON codec、结果 codec,以及保留的最后一个 `signal` 参数是否具有全局 `AbortSignal` 类型,并生成严格 descriptor。 LIB 运行时只加载 `lib` 中的 definition,不启动 TypeScript compiler。Host Gateway 后续的 Service 关联、lookup、Context 解析、调用和响应编码不区分 descriptor 来自 SRC 弱解析还是 LIB 严格生成。 @@ -348,17 +354,18 @@ Host Gateway 向 Connection 注册一个 `/api` interceptor,不维护第二份 每次调用都会重新从当前状态解析 descriptor、receiver、lookup 提供方与 Context 提供方。当前 strict descriptor 优先于 SRC。strict endpoint 一旦出现,即使随后撤回对应 descriptor,`TypeRTLocalRegistry.hasSeen()` 仍会在注册表剩余生命周期内保持对它的认领并禁止回退 SRC;重新注册 strict descriptor 即可恢复调用。移除 Service 或提供方会让调用明确失败;Gateway 既不保留失效对象,也不会以原始 lookup ID 调用方法。 -普通 `@Remote` 调用保留原始 Service 实例作为 receiver。lookup 成功后,Gateway 按 descriptor 的参数顺序调用 `implementation ?? method` 指定的成员。 +普通 `@Remote` 调用保留原始 Service 实例作为 receiver。lookup 成功后,Gateway 按 descriptor 的参数顺序调用 `implementation ?? method` 指定的成员;若 descriptor 声明取消,则在这些参数之后追加 carrier signal。 `@RemoteContext('agent')` 调用先由 Agent Context provider 解析 wire identity,再从该 Context 读取 descriptor 的 service key 并调用 scoped receiver。业务方法不会收到隐藏 Context 参数或 Agent ID。 ```text -ctx.typertGateway.invoke({ namespace, method, args }) +ctx.typertGateway.invoke({ namespace, method, args, signal }) → 查找本地 InvocationDescriptor 与 live receiver → 按参数 descriptor 读取具名 wire 字段 → codec 解码普通值或 lookup ID → lookup provider 把 ID 解析为活对象 → direct 使用原 Service;context 先解析 scoped Context 和 Service +→ cancellation descriptor 存在时把 signal 追加到业务参数末尾 → Reflect.apply(receiver[implementation ?? method], receiver, orderedArgs) → result codec 编码业务结果 ``` @@ -373,10 +380,10 @@ Connection 在 HTTP Server 上持有唯一 `/api` route。Gateway 把同步 endp ctx.connection.rpc.intercept( '/api', endpoint => ownsRemoteEndpoint(endpoint), - (endpoint, payload) => { + (endpoint, payload, signal) => { const { namespace, method } = parseEndpoint(endpoint) const { args } = parsePayload(payload) - return ctx.typertGateway.invoke({ namespace, method, args }) + return ctx.typertGateway.invoke({ namespace, method, args, signal }) }, ) ``` @@ -405,15 +412,16 @@ Remote payload 使用具名 JSON 对象,不使用位置数组,也不发送 ` 完整链路为: ```text -ctx.api.goals.create(sessionId, request) +ctx.api.goals.create(sessionId, request, signal?) → Client InvocationDescriptor 编码 { args: { agentId, request } } -→ ctx.connection.rpc.call('/api', 'goals/create', { args }) +→ Client 合并 caller signal 与 contribution mount lifetime +→ ctx.connection.rpc.call('/api', 'goals/create', { args }, signal) → Connection 创建 rpcId 和既有 client-request envelope → 当前 carrier 发送 POST /api/goals/create → Connection Host half 执行共享 trust,再由 bridge 创建标准 Request → 复合 FetchHandler 判断 endpoint ownership 并选择目标 FetchHandler -→ TypeRT interceptor 调用 ctx.typertGateway.invoke(...) -→ Host InvocationDescriptor 解码、lookup、receiver 解析和 Reflect.apply +→ TypeRT interceptor 调用 ctx.typertGateway.invoke(..., request.signal) +→ Host InvocationDescriptor 解码、lookup、receiver 解析并把 signal 注入 Reflect.apply → result codec 编码 → Connection 写入既有 RPC result 并回送相同 rpcId → Client result codec 验证并返回 CreateGoalResult @@ -421,7 +429,7 @@ ctx.api.goals.create(sessionId, request) Remote 不定义第二层 `{ ok, value/error }` response。成功值和 Gateway 错误直接使用既有 RPC response 的 `result`。当前 adapter 把所有 Gateway 与业务调用失败转换为既有 `RpcError` envelope,并统一使用 `code: 'internal'`;Gateway 的结构化错误分类仅在进程内保留,诊断信息则通过 message 跨 Connection 传递。 -Gateway 不处理逐方法权限、调用者身份、取消、幂等或长连接状态。TypeRT endpoint 使用 Connection 的 trusted-host 策略;未认领 endpoint 保留旧 API Proxy 的 trust 和 privileged-method 策略。Connection/WebSocket 迁移后续独立完成。 +Gateway 不处理逐方法权限、调用者身份、幂等或长连接状态。它只把 Connection 的协作式取消传播给显式支持取消的业务方法。TypeRT endpoint 使用 Connection 的 trusted-host 策略;未认领 endpoint 保留旧 API Proxy 的 trust 和 privileged-method 策略。Connection/WebSocket 迁移后续独立完成。 ## Connection 与协议边界 @@ -475,6 +483,7 @@ Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebS - Root 与 Agent-scoped 调用会经过真实的共享 `/api` carrier,将 `agentId` 解析为活 Agent,调用原始 Goal receiver,并通过既有 RPC envelope 返回。 - Remote 产物与 map 仅包含已标记的方法,不依赖 Browser,从而为未来 TUI 保留相同的消费方边界。 - 生命周期测试会撤回并重新挂载 descriptor、Service、lookup、Context 提供方和 Client namespace;依赖不可用时,调用会失败,且不会使用陈旧调用或回退原始 ID。 +- 取消测试覆盖严格生成、SRC 末位参数名识别、Client signal 合并、Connection 到 Gateway 的传播,以及 Host 在 wire `args` 之外的注入。 - 未认领 endpoint 继续使用既有 API Proxy 路径,其 trust、privileged-method、Permission/Approval 与 Session 事件流行为保持不变。 ## 后果 @@ -499,4 +508,4 @@ Remote endpoint 使用 Connection 的 `trusted-host` authority。系统默认接 `hasSeen()` 优先保障 strict definition 的安全性,而非 SRC 可用性。strict descriptor 撤回时(例如 HMR 期间),Gateway 会继续认领 endpoint 并报告不可用,而不会回退到弱 SRC descriptor。重新注册即可恢复;只有重启 TypeRT 注册表才会忘记历史 strict definition。 -Connection 提供 `AbortSignal`,但 Remote 业务签名没有取消参数。因此 Client 断连不会取消业务工作;取消仍作为后续工作,而不能由 transport handler 的形状暗示已经支持。 +支持取消的 Remote 签名会接收 Connection 请求的 `AbortSignal`,因此 HTTP 断连或 Client 侧 abort 能在不进入 JSON 协议的情况下传递到正在进行的业务工作。取消仍是协作式的:没有保留末位参数的方法会继续运行;收到 signal 的方法必须将它传给自身支持取消的操作,或自行观测它。 diff --git a/docs/core-data-structures/typert.i18n.yaml b/docs/core-data-structures/typert.i18n.yaml index be40eeb20a..a5484d06c4 100644 --- a/docs/core-data-structures/typert.i18n.yaml +++ b/docs/core-data-structures/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/core-data-structures/typert.md -typert.md: 9f5c63fc554a43fd0248ed08a64dcff566c83b58 -typert.zh.md: 2b74c8325a510ba39d134fa6d463dab273239772 +typert.md: da6e229ff6a2300c36f5734ad05c621a5e63082d +typert.zh.md: b3b0e8897756b5b4f9b645522cc5a1b27eac1d33 diff --git a/docs/core-data-structures/typert.md b/docs/core-data-structures/typert.md index 9f5c63fc55..da6e229ff6 100644 --- a/docs/core-data-structures/typert.md +++ b/docs/core-data-structures/typert.md @@ -38,7 +38,7 @@ interface TypeRTLookupDefinition { ## Invocation descriptors -An `InvocationDescriptor` is local reflection, not a wire message. Host and consumer builds generate corresponding descriptors; the request sends only the endpoint and named `args`. Strict codecs carry generated schemas, while SRC codecs enforce JSON-safe values without structural type recovery. +An `InvocationDescriptor` is local reflection, not a wire message. Host and consumer builds generate corresponding descriptors; the request sends only the endpoint and named `args`. Strict codecs carry generated schemas, while SRC codecs enforce JSON-safe values without structural type recovery. Cancellation is an out-of-band carrier signal injected after business parameters and never enters `args`. ```ts type-equiv /** Codec attached to one invocation parameter or result. */ @@ -100,6 +100,11 @@ interface InvocationDescriptor { } /** Ordered business parameters. */ readonly parameters: readonly InvocationParameterDescriptor[] + /** Transport cancellation injected after business parameters instead of entering wire args. */ + readonly cancellation?: { + /** Reserved final Host method parameter. */ + readonly parameter: 'signal' + } /** Codec for the resolved method result. */ readonly result: TypeRTCodec /** Source declaration used only for diagnostics. */ @@ -130,7 +135,7 @@ interface TypeRTRemoteNamespaceMap {} ## Host Gateway -Connection decodes its carrier envelope before calling `ctx.typertGateway`. The request carries exact named wire fields; infrastructure and boundary failures use the Gateway's in-process error taxonomy, although the current RPC adapter folds them into the transport's `internal` error code. +Connection decodes its carrier envelope before calling `ctx.typertGateway`. The request carries exact named wire fields and the carrier's cancellation signal separately; infrastructure and boundary failures use the Gateway's in-process error taxonomy, although the current RPC adapter folds them into the transport's `internal` error code. ```ts type-equiv /** One Remote method request after a carrier has decoded its envelope. */ @@ -141,6 +146,8 @@ interface InvokeRemoteRequest { readonly method: string /** Named wire values; fields must exactly match the descriptor. */ readonly args: Readonly> + /** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */ + readonly signal?: AbortSignal } ``` diff --git a/docs/core-data-structures/typert.zh.md b/docs/core-data-structures/typert.zh.md index 2b74c8325a..b3b0e88977 100644 --- a/docs/core-data-structures/typert.zh.md +++ b/docs/core-data-structures/typert.zh.md @@ -38,7 +38,7 @@ interface TypeRTLookupDefinition { ## 调用 descriptor -`InvocationDescriptor` 是本地反射信息,不是 wire message。Host 与消费方构建会生成彼此对应的 descriptor;请求只发送 endpoint 与具名 `args`。strict codec 携带生成的 schema,SRC codec 则在不恢复结构类型的前提下强制要求 JSON 安全值。 +`InvocationDescriptor` 是本地反射信息,不是 wire message。Host 与消费方构建会生成彼此对应的 descriptor;请求只发送 endpoint 与具名 `args`。strict codec 携带生成的 schema,SRC codec 则在不恢复结构类型的前提下强制要求 JSON 安全值。取消通过带外 carrier signal 表达:它在业务参数之后注入,绝不进入 `args`。 ```ts type-equiv /** Codec attached to one invocation parameter or result. */ @@ -100,6 +100,11 @@ interface InvocationDescriptor { } /** Ordered business parameters. */ readonly parameters: readonly InvocationParameterDescriptor[] + /** Transport cancellation injected after business parameters instead of entering wire args. */ + readonly cancellation?: { + /** Reserved final Host method parameter. */ + readonly parameter: 'signal' + } /** Codec for the resolved method result. */ readonly result: TypeRTCodec /** Source declaration used only for diagnostics. */ @@ -130,7 +135,7 @@ interface TypeRTRemoteNamespaceMap {} ## Host Gateway -Connection 会先解码 carrier envelope,再调用 `ctx.typertGateway`。请求携带精确的具名 wire 字段;基础设施与边界失败使用 Gateway 的进程内错误分类体系,但当前 RPC 适配器会把这些错误折叠为传输层的 `internal` 错误码。 +Connection 会先解码 carrier envelope,再调用 `ctx.typertGateway`。请求将精确的具名 wire 字段与 carrier 的取消 signal 分开携带;基础设施与边界失败使用 Gateway 的进程内错误分类体系,但当前 RPC 适配器会把这些错误折叠为传输层的 `internal` 错误码。 ```ts type-equiv /** One Remote method request after a carrier has decoded its envelope. */ @@ -141,6 +146,8 @@ interface InvokeRemoteRequest { readonly method: string /** Named wire values; fields must exactly match the descriptor. */ readonly args: Readonly> + /** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */ + readonly signal?: AbortSignal } ``` diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 4fe2b12323..d8d067ce3e 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -2097,7 +2097,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'InvocationDescriptor', - declaration: 'export interface InvocationDescriptor {\n readonly id: string;\n readonly service: string;\n readonly namespace: string;\n readonly method: string;\n readonly implementation?: string;\n readonly invocation: {\n readonly kind: \'direct\';\n } | {\n readonly kind: \'context\';\n readonly context: string;\n readonly wire: string;\n readonly codec: TypeRTCodec;\n };\n readonly scope?: {\n readonly context: string;\n readonly wire: string;\n };\n readonly parameters: readonly InvocationParameterDescriptor[];\n readonly result: TypeRTCodec;\n readonly sourceLocation?: InvocationSourceLocation;\n}', + declaration: 'export interface InvocationDescriptor {\n readonly id: string;\n readonly service: string;\n readonly namespace: string;\n readonly method: string;\n readonly implementation?: string;\n readonly invocation: {\n readonly kind: \'direct\';\n } | {\n readonly kind: \'context\';\n readonly context: string;\n readonly wire: string;\n readonly codec: TypeRTCodec;\n };\n readonly scope?: {\n readonly context: string;\n readonly wire: string;\n };\n readonly parameters: readonly InvocationParameterDescriptor[];\n readonly cancellation?: {\n readonly parameter: \'signal\';\n };\n readonly result: TypeRTCodec;\n readonly sourceLocation?: InvocationSourceLocation;\n}', }, { name: 'InvocationParameterDescriptor', @@ -2109,7 +2109,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'InvokeRemoteRequest', - declaration: 'export interface InvokeRemoteRequest {\n readonly namespace: string;\n readonly method: string;\n readonly args: Readonly>;\n}', + declaration: 'export interface InvokeRemoteRequest {\n readonly namespace: string;\n readonly method: string;\n readonly args: Readonly>;\n readonly signal?: AbortSignal;\n}', }, { name: 'JsonSchemaNode', diff --git a/packages/host/api-gateway/README.i18n.yaml b/packages/host/api-gateway/README.i18n.yaml index 747aa65665..a1c22433f3 100644 --- a/packages/host/api-gateway/README.i18n.yaml +++ b/packages/host/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/host/api-gateway/README.md -README.md: cc80bb19fec15414aa0857154a8a36fb4f642672 -README.zh.md: 6febb1cfe4fc7fa4c5a17e1e4f6a21e2ee03e295 +README.md: 9cb6e7e1c0a23789ab4ab2c999b5a6c2d4cd32f9 +README.zh.md: 609580ceb77649ba8df6103093a72092c9ccc8a1 diff --git a/packages/host/api-gateway/README.md b/packages/host/api-gateway/README.md index cc80bb19fe..9cb6e7e1c0 100644 --- a/packages/host/api-gateway/README.md +++ b/packages/host/api-gateway/README.md @@ -12,11 +12,13 @@ Strict mode reads generated invocation descriptors from `ctx.typert.local`. Look 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 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. + ## Client service: `ClientApi` (ctx key: `api`) `ctx.api.mount()` validates and registers a generated Host-for-Client contribution, then installs concrete direct and scoped methods for the calling Cordis fiber. Duplicate endpoints, namespace collisions, and descriptors without strict generated codecs fail before methods become callable. -Each call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api', endpoint, ...)`. The returned value is validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls, and makes retained method handles reject. +Each call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api', endpoint, ...)`. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before calling Connection. The returned value is validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls, and makes retained method handles reject. Generated declaration merges provide the TypeScript API. 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/host/api-gateway/README.zh.md b/packages/host/api-gateway/README.zh.md index 6febb1cfe4..609580ceb7 100644 --- a/packages/host/api-gateway/README.zh.md +++ b/packages/host/api-gateway/README.zh.md @@ -12,11 +12,13 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领 endpoint 则交给 API Proxy。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 可区分分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。 +支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。 + ## Client 服务:`ClientApi`(ctx key:`api`) `ctx.api.mount()` 会校验并注册生成的 Host-for-Client 贡献项,然后为发起调用的 Cordis fiber 安装具体的直接方法和作用域方法。重复端点、命名空间冲突,以及缺少生成的严格编解码器的描述符,都会在方法可调用前报错。 -每次调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。返回值经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用,并使外部仍持有的方法句柄在调用时返回拒绝。 +每次调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用 Connection 前将它与贡献项的挂载生命周期合并。返回值经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用,并使外部仍持有的方法句柄在调用时返回拒绝。 生成的声明合并提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。 diff --git a/packages/host/api-gateway/src/client/index.ts b/packages/host/api-gateway/src/client/index.ts index 5cd8ab75d1..292df54152 100644 --- a/packages/host/api-gateway/src/client/index.ts +++ b/packages/host/api-gateway/src/client/index.ts @@ -223,9 +223,13 @@ class ClientApiService extends Service implements ClientApi { const endpoint = endpointOf(descriptor) if (!token.active) throw new Error(`client api: Remote method ${endpoint} is no longer mounted`) const expected = descriptor.parameters.length - (projection?.parameterIndex === undefined ? 0 : 1) - if (values.length !== expected) { + const hasCallerSignal = descriptor.cancellation !== undefined && values.length === expected + 1 + if (values.length !== expected && !hasCallerSignal) { + const contract = descriptor.cancellation === undefined + ? `${String(expected)} argument(s)` + : `${String(expected)} business argument(s) plus an optional AbortSignal` throw new Error( - `client api: ${endpoint} expected ${String(expected)} argument(s), got ${String(values.length)}`, + `client api: ${endpoint} expected ${contract}, got ${String(values.length)}`, ) } const args: Record = {} @@ -248,7 +252,11 @@ class ClientApiService extends Service implements ClientApi { }) const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined if (connection === undefined) throw new Error(`client api: ${endpoint} has no active Connection`) - const result = await connection.rpc.call('/api', endpoint, { args }, token.abort.signal) + const callerSignal = hasCallerSignal ? values[expected] as AbortSignal | undefined : undefined + const signal = callerSignal === undefined + ? token.abort.signal + : AbortSignal.any([token.abort.signal, callerSignal]) + const result = await connection.rpc.call('/api', endpoint, { args }, signal) if (!mountActive(token)) throw new Error(`client api: Remote method ${endpoint} was withdrawn during invocation`) if (!result.ok) throw remoteFailure(endpoint, result.error) return parse(descriptor.result, result.value, endpoint, 'result') diff --git a/packages/host/api-gateway/src/index.ts b/packages/host/api-gateway/src/index.ts index 64d5715719..c4a61cef8d 100644 --- a/packages/host/api-gateway/src/index.ts +++ b/packages/host/api-gateway/src/index.ts @@ -36,6 +36,7 @@ interface ResolvedBinding { } type ConnectionRpcResult = Awaited> +const NEVER_ABORTED_SIGNAL = new AbortController().signal /** Dispatch failure produced outside the invoked business method. */ export class TypertGatewayError extends Error { @@ -129,6 +130,7 @@ export class TypertGatewayService extends Service implements TypertGateway { } validateBinding(receiver, descriptor.service, descriptor.namespace, endpoint) const args = descriptor.parameters.map(parameter => this.resolveParameter(parameter, request.args, endpoint)) + if (descriptor.cancellation !== undefined) args.push(request.signal ?? NEVER_ABORTED_SIGNAL) const implementation = descriptor.implementation ?? descriptor.method const method = Reflect.get(receiver, implementation) as unknown if (typeof method !== 'function') { @@ -146,13 +148,12 @@ export class TypertGatewayService extends Service implements TypertGateway { private async dispatchRpc( endpoint: string, payload: unknown, - _signal: AbortSignal, + signal: AbortSignal, ): Promise { - // Remote methods have no cancellation parameter yet, so disconnects do not cancel business work. - return this.invokeRpc(endpoint, payload) + return this.invokeRpc(endpoint, payload, signal) } - private async invokeRpc(endpoint: string, payload: unknown): Promise { + private async invokeRpc(endpoint: string, payload: unknown, signal: AbortSignal): Promise { try { const segments = endpoint.split('/') if (segments.length !== 2 || segments[0] === '' || segments[1] === '') { @@ -171,6 +172,7 @@ export class TypertGatewayService extends Service implements TypertGateway { namespace, method, args: payload.args, + signal, }) return { ok: true, value } } catch (error) { @@ -226,9 +228,22 @@ export class TypertGatewayService extends Service implements TypertGateway { endpoint: string, ): InvocationDescriptor { const names = methodParameterNames(binding.service, marker.method, endpoint) + const signalIndex = names.indexOf('signal') + if (signalIndex >= 0 && signalIndex !== names.length - 1) { + throw new TypertGatewayError( + 'signature-invalid', + endpoint, + 'SRC cancellation parameter signal must be the final parameter', + { field: 'signal' }, + ) + } + const cancellation = signalIndex >= 0 + ? { parameter: 'signal' as const } + : undefined + const businessNames = cancellation === undefined ? names : names.slice(0, -1) const parameters: InvocationParameterDescriptor[] = [] const wires = new Set() - for (const name of names) { + for (const name of businessNames) { const matches = this.ctx.typert.lookups.definitions() .filter(definition => definition.parameter === name) if (matches.length > 1) { @@ -295,6 +310,7 @@ export class TypertGatewayService extends Service implements TypertGateway { ...(marker.method === method ? {} : { implementation: marker.method }), invocation: receiver, parameters, + ...(cancellation === undefined ? {} : { cancellation }), result: { mode: 'src-json' }, } } diff --git a/packages/host/api-gateway/src/types.ts b/packages/host/api-gateway/src/types.ts index eea2bdc4f1..b7f36eb340 100644 --- a/packages/host/api-gateway/src/types.ts +++ b/packages/host/api-gateway/src/types.ts @@ -11,6 +11,8 @@ export interface InvokeRemoteRequest { readonly method: string /** Named wire values; fields must exactly match the descriptor. */ readonly args: Readonly> + /** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */ + readonly signal?: AbortSignal } /** Stable infrastructure and boundary failures emitted before or after business execution. */ diff --git a/packages/host/api-gateway/tests/client.spec.ts b/packages/host/api-gateway/tests/client.spec.ts index 2e00d29c0d..3ad00ff0fc 100644 --- a/packages/host/api-gateway/tests/client.spec.ts +++ b/packages/host/api-gateway/tests/client.spec.ts @@ -17,11 +17,18 @@ declare module '@deepseek-ai/dsh-type-meta' { } interface TypeRTRemoteMap { - 'goals/create': (agentId: string, request: { readonly objective: string }) => Promise<{ readonly ref: string }> + 'goals/create': ( + agentId: string, + request: { readonly objective: string }, + signal?: AbortSignal, + ) => Promise<{ readonly ref: string }> } interface TypeRTRemoteContextMap { - 'fixture:goals/create': (request: { readonly objective: string }) => Promise<{ readonly ref: string }> + 'fixture:goals/create': ( + request: { readonly objective: string }, + signal?: AbortSignal, + ) => Promise<{ readonly ref: string }> 'fixture:goals/rename': (request: { readonly objective: string }) => Promise<{ readonly renamed: boolean }> } @@ -58,6 +65,7 @@ function directDescriptor(): InvocationDescriptor { source: 'json', codec: { mode: 'strict', typeSymbol: '@fixture#CreateRequest', schema: requestSchema }, }], + cancellation: { parameter: 'signal' }, result: { mode: 'strict', typeSymbol: '@fixture#CreateResult', schema: createResultSchema }, } } @@ -114,6 +122,19 @@ describe('Client TypeRT API', () => { { args: { agentId: 'agent-1', request: { objective: 'ship' } } }, expect.any(AbortSignal), ) + const callerAbort = new AbortController() + await expect(ctx.api.goals.create( + 'agent-1', + { objective: 'cancel me' }, + callerAbort.signal, + )).resolves.toEqual({ ref: 'goal-1' }) + const combinedSignal = call.mock.calls.at(-1)?.[3] + expect(combinedSignal).toBeInstanceOf(AbortSignal) + expect(combinedSignal).not.toBe(callerAbort.signal) + const cancellation = new Error('caller cancelled') + callerAbort.abort(cancellation) + expect(combinedSignal?.aborted).toBe(true) + expect(combinedSignal?.reason).toBe(cancellation) await expect(ctx.api.goals.create('', { objective: 'ship' })).rejects.toThrow('rejected "agentId"') call.mockResolvedValueOnce({ ok: true, value: { ref: 1 } }) @@ -299,10 +320,18 @@ describe('Client TypeRT API', () => { .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } }) const ctx = await bench(call) const descriptor = directDescriptor() - const dispose = ctx.api.mount({ package: '@fixture/goals', descriptors: [descriptor] }) + const dispose = ctx.api.mount({ + package: '@fixture/goals', + descriptors: [descriptor, contextDescriptor()], + }) const create = ctx.api.goals.create as unknown as (...args: unknown[]) => Promise + const goals = (ctx as FixtureContext).goals + const rename = goals.rename as unknown as (...args: unknown[]) => Promise - await expect(create('agent-1')).rejects.toThrow('expected 2 argument(s), got 1') + await expect(create('agent-1')).rejects.toThrow('expected 2 business argument(s) plus an optional AbortSignal, got 1') + await expect(create('agent-1', { objective: 'ship' }, undefined, 'extra')) + .rejects.toThrow('got 4') + await expect(rename.call(goals)).rejects.toThrow('expected 1 argument(s), got 0') await expect((ctx as FixtureContext).goals.create({ objective: 'ship' })) .rejects.toThrow('no Client Context binder') diff --git a/packages/host/api-gateway/tests/gateway.spec.ts b/packages/host/api-gateway/tests/gateway.spec.ts index 4aeadeedb8..c05bfefb93 100644 --- a/packages/host/api-gateway/tests/gateway.spec.ts +++ b/packages/host/api-gateway/tests/gateway.spec.ts @@ -45,6 +45,7 @@ const emptyModel: TypertContribution['model'] = { class GoalService extends Service { readonly typertGateway = bindTypeRTGateway(this, 'goals') readonly calls: string[] = [] + lastSignal: AbortSignal | undefined nextResult: unknown = undefined businessError: Error | undefined @@ -53,8 +54,9 @@ class GoalService extends Service { } @Remote - create(agent: FixtureAgent, request: { readonly title: string }): unknown { + create(agent: FixtureAgent, request: { readonly title: string }, signal: AbortSignal): unknown { this.calls.push('create') + this.lastSignal = signal return { agentId: agent.id, title: request.title, @@ -224,6 +226,19 @@ class RestParameterService extends Service { } } +class NonFinalSignalService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'nonFinalSignal', { namespace: 'invalid-signal' }) + + constructor(ctx: Context) { + super(ctx, 'nonFinalSignal') + } + + @Remote + run(signal: AbortSignal, value: string): string { + return signal.aborted ? '' : value + } +} + class WrongBindingService extends Service { readonly typertGateway = bindTypeRTGateway(this, 'notWrongBinding', { namespace: 'wrong-binding' }) @@ -334,13 +349,24 @@ describe('TypertGatewayService', () => { registerAgentLookup(ctx, agent) registerStrict(ctx, [createDescriptor()]) const caller = ctx.extend({ fixtureScope: 'direct-caller' }) + const abort = new AbortController() await expect(caller.typertGateway.invoke({ namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: ' ship ' } }, + signal: abort.signal, })).resolves.toEqual({ agentId: 'agent-1', title: 'ship', scope: 'direct-caller' }) expect(service.calls).toEqual(['create']) + expect(service.lastSignal).toBe(abort.signal) + + await expect(caller.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: { agentId: 'agent-1', request: { title: 'again' } }, + })).resolves.toEqual({ agentId: 'agent-1', title: 'again', scope: 'direct-caller' }) + expect(service.lastSignal).toBeInstanceOf(AbortSignal) + expect(service.lastSignal?.aborted).toBe(false) }) it('resolves strict Remote Context identity without adding a business argument', async () => { @@ -358,16 +384,19 @@ describe('TypertGatewayService', () => { }) it('derives SRC direct lookup and JSON parameters from marker and parameter names', async () => { - const { ctx } = await setup() + const { ctx, service } = await setup() const agent = { id: 'agent-1' } registerAgentLookup(ctx, agent) const caller = ctx.extend({ fixtureScope: 'direct-src' }) + const abort = new AbortController() await expect(caller.typertGateway.invoke({ namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, + signal: abort.signal, })).resolves.toEqual({ agentId: 'agent-1', title: 'ship', scope: 'direct-src' }) + expect(service.lastSignal).toBe(abort.signal) }) it('does not downgrade an observed SRC lookup after its provider unloads', async () => { @@ -605,6 +634,7 @@ describe('TypertGatewayService', () => { { plugin: DefaultParameterService, namespace: 'invalid-default', args: { value: 'x' } }, { plugin: DestructuredParameterService, namespace: 'invalid-destructure', args: { value: { value: 'x' } } }, { plugin: RestParameterService, namespace: 'invalid-rest', args: { values: ['x'] } }, + { plugin: NonFinalSignalService, namespace: 'invalid-signal', args: { value: 'x' } }, ] as const for (const testCase of cases) { const ctx = await setupGateway() @@ -874,7 +904,8 @@ describe('TypertGatewayService', () => { expect(connection.matches?.('goals')).toBe(false) expect(connection.matches?.('goals/missing')).toBe(false) expect(connection.matches?.('legacy/list')).toBe(false) - const signal = new AbortController().signal + const abort = new AbortController() + const signal = abort.signal const handler = connection.handler if (handler === undefined) throw new Error('fixture Connection did not retain the /api interceptor') await expect(handler('goals/create', { @@ -883,6 +914,10 @@ describe('TypertGatewayService', () => { ok: true, value: { agentId: 'agent-1', title: 'ship', scope: 'rpc-caller' }, }) + const service = rawGoalService(ctx) + expect(service.lastSignal).toBe(signal) + abort.abort(new Error('client disconnected')) + expect(service.lastSignal?.aborted).toBe(true) const invalid = await handler('goals/create', { invalid: true }, signal) expect(invalid).toMatchObject({ ok: false, @@ -904,7 +939,6 @@ describe('TypertGatewayService', () => { expect(result.error.message).toContain('plain-object args field') } - const service = rawGoalService(ctx) service.businessError = 'non-error failure' as unknown as Error await expect(handler('goals/fail', { args: { request: null } }, signal)).resolves.toEqual({ ok: false, @@ -1099,6 +1133,7 @@ function createDescriptor(): InvocationDescriptor { })), }, ], + cancellation: { parameter: 'signal' }, result: strictCodec('@fixture/gateway#CreateResult', z.object({ agentId: z.string(), title: z.string(), diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index f430d757fb..87a23f17f5 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -963,8 +963,9 @@ class FaceAnalyzer { const lookups = this.lookupDeclarations() const lookupByHost = new Map(lookups.map(lookup => [lookup.hostSymbol, lookup])) const parameters: InvocationParameterModel[] = [] + let cancellation: InvocationModel['cancellation'] const wires = new Set() - for (const parameter of method.parameters) { + for (const [parameterIndex, parameter] of method.parameters.entries()) { if (!ts.isIdentifier(parameter.name)) { this.fail(parameter, 'Remote parameters must use identifier bindings') } @@ -973,6 +974,18 @@ class FaceAnalyzer { if (parameter.questionToken !== undefined) this.fail(parameter, 'Remote parameters cannot be optional') if (parameter.name.text === 'this') this.fail(parameter, 'Remote methods cannot declare an explicit this parameter') const authoredType = this.requiredType(parameter, parameter.type, 'parameter') + const cancellationName = parameter.name.text === 'signal' + const cancellationType = this.isGlobalAbortSignal(authoredType) + if (cancellationName || cancellationType) { + if (!cancellationName || !cancellationType) { + this.fail(parameter, 'Remote cancellation must use a parameter named signal with the global AbortSignal type') + } + if (parameterIndex !== method.parameters.length - 1) { + this.fail(parameter, 'Remote cancellation signal must be the final parameter') + } + cancellation = { parameter: 'signal' } + continue + } const hostSymbol = this.symbolAtType(authoredType) const lookup = hostSymbol === undefined ? undefined : lookupByHost.get(this.symbolId(hostSymbol)) let modeled: InvocationParameterModel @@ -1065,6 +1078,7 @@ class FaceAnalyzer { invocation: receiver, ...(scope === undefined ? {} : { scope }), parameters, + ...(cancellation === undefined ? {} : { cancellation }), result: this.remoteBoundary( resultType, `${registration.name}#${binding.namespace}/${exportedMethod}:result`, @@ -1181,6 +1195,13 @@ class FaceAnalyzer { return resultType } + private isGlobalAbortSignal(type: ts.TypeNode): boolean { + const symbol = this.symbolAtType(type) + if (symbol?.name !== 'AbortSignal') return false + return symbol.declarations?.some(declaration => + isStandardLibraryFile(declaration.getSourceFile().fileName)) === true + } + private lookupDeclarations(): readonly StaticLookupDeclaration[] { if (this.staticLookups !== undefined) return this.staticLookups const byKey = new Map() diff --git a/packages/typert/generator/src/emitter.ts b/packages/typert/generator/src/emitter.ts index 63b1ee7ace..c8b9ab4195 100644 --- a/packages/typert/generator/src/emitter.ts +++ b/packages/typert/generator/src/emitter.ts @@ -315,6 +315,9 @@ export class FaceModelEmitter { lines.push(' },') }) lines.push(' ],') + if (invocation.cancellation !== undefined) { + lines.push(" cancellation: { parameter: 'signal' },") + } lines.push(` result: ${indent(strictCodec( invocation.result, schemas.boundary(resultBoundaryKey(invocation)), @@ -459,6 +462,7 @@ export class FaceModelEmitter { const parameters = invocation.parameters.filter(parameter => !scoped || invocation.invocation.kind === 'context' || parameter.wire !== invocation.scope?.wire).map(parameter => `${safeIdentifier(parameter.wire)}: ${this.renderer.renderType(parameter.boundary.type, referenceNames)}`) + if (invocation.cancellation !== undefined) parameters.push('signal?: AbortSignal') const result = this.renderer.renderType(invocation.result.type, referenceNames) return `(${parameters.join(', ')}) => Promise<${result}>` } diff --git a/packages/typert/generator/src/model.ts b/packages/typert/generator/src/model.ts index 7f15c8407c..81bc6a91a1 100644 --- a/packages/typert/generator/src/model.ts +++ b/packages/typert/generator/src/model.ts @@ -140,6 +140,9 @@ export interface InvocationModel { readonly wire: string } readonly parameters: readonly InvocationParameterModel[] + readonly cancellation?: { + readonly parameter: 'signal' + } readonly result: RemoteBoundaryModel readonly location: SourceLocation } diff --git a/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts index 816a13a5a7..115b3b87a6 100644 --- a/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts +++ b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts @@ -12,7 +12,8 @@ export class GoalService { readonly typertGateway = bindTypeRTGateway(this, 'goals') @Remote - async create(agent: Agent, request: CreateGoalRequest): Promise { + async create(agent: Agent, request: CreateGoalRequest, signal: AbortSignal): Promise { + signal.throwIfAborted() return { ref: `${agent.id}:${request.title}` } } diff --git a/packages/typert/generator/tests/remote-model.spec.ts b/packages/typert/generator/tests/remote-model.spec.ts index cb6e6e6060..d5838f39ce 100644 --- a/packages/typert/generator/tests/remote-model.spec.ts +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -16,6 +16,7 @@ interface RuntimeSchema { interface RuntimeDescriptor { readonly id: string + readonly cancellation?: { readonly parameter: 'signal' } readonly parameters: readonly { readonly wire: string readonly codec: { readonly schema: RuntimeSchema } @@ -83,6 +84,7 @@ describe('Remote model generation', { timeout: 60_000 }, () => { boundary: { typeSymbol: '@fixture/remote/types#CreateGoalRequest' }, }, ], + cancellation: { parameter: 'signal' }, result: { typeSymbol: '@fixture/remote/types#CreateGoalResult' }, }) expect(model.invocations[1]).toMatchObject({ @@ -107,12 +109,12 @@ describe('Remote model generation', { timeout: 60_000 }, () => { expect(artifact?.js).toContain('invocations: [') expect(artifact?.remote?.dts).toContain( - "'goals/create': (agentId: AgentId, request: CreateGoalRequest) => Promise", + "'goals/create': (agentId: AgentId, request: CreateGoalRequest, signal?: AbortSignal) => Promise", ) expect(artifact?.remote?.dts).toContain('interface TypeRTRemoteNamespace$676f616c73 {\n create:') expect(artifact?.remote?.dts).toContain("'goals': TypeRTRemoteNamespace$676f616c73") expect(artifact?.remote?.dts).toContain( - "'agent:goals/create': (request: CreateGoalRequest) => Promise", + "'agent:goals/create': (request: CreateGoalRequest, signal?: AbortSignal) => Promise", ) expect(artifact?.remote?.dts).toContain( "'agent:goals/rename': (request: RenameGoalRequest) => Promise", @@ -124,6 +126,7 @@ describe('Remote model generation', { timeout: 60_000 }, () => { const generated = await import(`data:text/javascript,${encodeURIComponent(executable)}`) as RuntimeRemoteModule expect(generated.TYPERT_REMOTE.package).toBe('@fixture/remote') const create = generated.TYPERT_REMOTE.descriptors[0] + expect(create?.cancellation).toEqual({ parameter: 'signal' }) expect(create?.parameters[1]?.codec.schema.safeParse({ title: 'ship' }).success).toBe(true) expect(create?.parameters[1]?.codec.schema.safeParse({ title: 1 }).success).toBe(false) expect(create?.result.schema.safeParse({ ref: 'goal-1' }).success).toBe(true) @@ -234,8 +237,8 @@ export type GenericResult = { edit: (source: string) => source .replace('export class GoalService', 'export abstract class GoalService') .replace( - ' async create(agent: Agent, request: CreateGoalRequest): Promise {\n return { ref: `${agent.id}:${request.title}` }\n }', - ' abstract create(agent: Agent, request: CreateGoalRequest): Promise', + ' async create(agent: Agent, request: CreateGoalRequest, signal: AbortSignal): Promise {\n signal.throwIfAborted()\n return { ref: `${agent.id}:${request.title}` }\n }', + ' abstract create(agent: Agent, request: CreateGoalRequest, signal: AbortSignal): Promise', ), message: 'Remote methods must have a concrete implementation', }, @@ -267,6 +270,24 @@ export type GenericResult = { edit: (source: string) => source.replace('request: CreateGoalRequest', 'request?: CreateGoalRequest'), message: 'Remote parameters cannot be optional', }, + { + name: 'wrong cancellation type', + edit: (source: string) => source.replace('signal: AbortSignal', 'signal: string'), + message: 'cancellation must use a parameter named signal with the global AbortSignal type', + }, + { + name: 'wrong cancellation name', + edit: (source: string) => source.replace('signal: AbortSignal', 'abort: AbortSignal'), + message: 'cancellation must use a parameter named signal with the global AbortSignal type', + }, + { + name: 'non-final cancellation', + edit: (source: string) => source.replace( + 'agent: Agent, request: CreateGoalRequest, signal: AbortSignal', + 'agent: Agent, signal: AbortSignal, request: CreateGoalRequest', + ), + message: 'cancellation signal must be the final parameter', + }, ])('rejects $name', ({ edit, message }) => { const root = copyFixture() editFile(root, 'packages/remote/src/index.ts', edit) @@ -399,12 +420,14 @@ declare const create: TypeRTRemoteMap['goals/create'] declare const createScoped: TypeRTRemoteContextMap['agent:goals/create'] declare const rename: TypeRTRemoteContextMap['agent:goals/rename'] const created: Promise = create('agent-1', { title: 'ship' }) +const cancellable: Promise = create('agent-1', { title: 'ship' }, new AbortController().signal) const createdScoped: Promise = createScoped({ title: 'ship' }) const renamed: Promise = rename({ ref: 'goal-1', title: 'land' }) declare const ctx: { api: TypeRTRemoteNamespaceMap } const navigated: Promise = ctx.api.goals.create('agent-1', { title: 'navigate' }) void contribution void created +void cancellable void createdScoped void renamed void navigated diff --git a/packages/typert/loader/src/index.ts b/packages/typert/loader/src/index.ts index efe0fa6f94..575d066e0d 100644 --- a/packages/typert/loader/src/index.ts +++ b/packages/typert/loader/src/index.ts @@ -226,6 +226,12 @@ function requireInvocation(pkgName: string, value: unknown): void { parameters.set(wire, parameter) requireStrictCodec(pkgName, parameter.codec, `invocation "${id}" parameter codec`) } + if (invocation.cancellation !== undefined) { + const cancellation = requireObject(pkgName, invocation.cancellation, `invocation "${id}" cancellation`) + if (cancellation.parameter !== 'signal') { + throw new Error(`typert-loader: ${pkgName} invocation "${id}" cancellation parameter must be "signal"`) + } + } if (invocation.scope !== undefined) { if (receiver.kind !== 'direct') { throw new Error(`typert-loader: ${pkgName} invocation "${id}" Context receiver cannot declare a direct scope projection`) diff --git a/packages/typert/loader/tests/loader.spec.ts b/packages/typert/loader/tests/loader.spec.ts index ec407f82d9..750cc92e57 100644 --- a/packages/typert/loader/tests/loader.spec.ts +++ b/packages/typert/loader/tests/loader.spec.ts @@ -83,6 +83,7 @@ function invocationTypertSource(pkgName: string): string { ' name: \'request\', wire: \'request\', source: \'json\',', ` codec: { mode: 'strict', typeSymbol: '${pkgName}/types#Request', schema: Text },`, ' }],', + " cancellation: { parameter: 'signal' },", ` result: { mode: 'strict', typeSymbol: '${pkgName}/types#Result', schema: Text },`, ' sourceLocation: { file: \'src/index.ts\', line: 8, column: 3 },', ' }],', @@ -157,6 +158,7 @@ describe('typert loader', () => { id: '@fixture/invocation#goals/create', invocation: { kind: 'direct' }, parameters: [{ wire: 'request', source: 'json' }], + cancellation: { parameter: 'signal' }, sourceLocation: { file: 'src/index.ts', line: 8, column: 3 }, }) expect(descriptor?.parameters[0]?.codec.mode).toBe('strict') @@ -502,6 +504,9 @@ describe('validateTypertManifest', () => { const descriptor = strictInvocation() const manifest = { ...base, invocations: [descriptor] } expect(validateTypertManifest('pkg', manifest)).toBe(manifest) + const cancellable = { ...descriptor, cancellation: { parameter: 'signal' } } + expect(validateTypertManifest('pkg', { ...base, invocations: [cancellable] }).invocations) + .toEqual([cancellable]) const scoped = { ...descriptor, scope: { context: 'agent', wire: 'agentId' }, @@ -526,6 +531,14 @@ describe('validateTypertManifest', () => { ...base, invocations: [{ ...descriptor, result: { mode: 'src-json' } }], })).toThrow('result codec must use a strict codec') + expect(() => validateTypertManifest('pkg', { + ...base, + invocations: [{ ...descriptor, cancellation: null }], + })).toThrow('cancellation must be an object') + expect(() => validateTypertManifest('pkg', { + ...base, + invocations: [{ ...descriptor, cancellation: { parameter: 'abort' } }], + })).toThrow('cancellation parameter must be "signal"') expect(() => validateTypertManifest('pkg', { ...base, invocations: [{ ...descriptor, result: { mode: 'strict', typeSymbol: 'pkg#Result', schema: zodish } }], diff --git a/packages/typert/registry/src/service.ts b/packages/typert/registry/src/service.ts index 6749cdbeb9..229dc7affc 100644 --- a/packages/typert/registry/src/service.ts +++ b/packages/typert/registry/src/service.ts @@ -562,6 +562,9 @@ function validateInvocation(descriptor: InvocationDescriptor): void { } validateCodec(parameter.codec, `${descriptor.id} parameter ${parameter.name}`) } + if (descriptor.cancellation !== undefined && descriptor.cancellation.parameter !== 'signal') { + throw new Error(`typert: invocation "${descriptor.id}" cancellation parameter must be "signal"`) + } if (descriptor.scope !== undefined) { if (descriptor.invocation.kind !== 'direct') { throw new Error(`typert: invocation "${descriptor.id}" Context receiver cannot declare a direct scope projection`) diff --git a/packages/typert/registry/tests/typert.spec.ts b/packages/typert/registry/tests/typert.spec.ts index 51e7594749..5603ce8954 100644 --- a/packages/typert/registry/tests/typert.spec.ts +++ b/packages/typert/registry/tests/typert.spec.ts @@ -411,6 +411,7 @@ describe('TypertRegistry', () => { ...invocation('@fixture/remote#strict'), implementation: 'remoteExportCreate', parameters: [{ name: 'request', wire: 'request', source: 'json', codec: strict }], + cancellation: { parameter: 'signal' }, result: strict, } const dispose = ctx.typert.remotes.register({ package: '@fixture/strict', descriptors: [strictInvocation] }) @@ -420,6 +421,10 @@ describe('TypertRegistry', () => { [{ ...invocation(), id: '' }, 'invocation id'], [{ ...invocation(), namespace: 'bad/name' }, 'namespace'], [{ ...invocation(), implementation: 'bad/name' }, 'implementation method'], + [{ + ...invocation(), + cancellation: { parameter: 'abort' } as unknown as { readonly parameter: 'signal' }, + }, 'cancellation parameter'], [{ ...invocation(), parameters: [ diff --git a/packages/typert/type-meta/README.i18n.yaml b/packages/typert/type-meta/README.i18n.yaml index 90d93152b7..9751c4c088 100644 --- a/packages/typert/type-meta/README.i18n.yaml +++ b/packages/typert/type-meta/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/typert/type-meta/README.md -README.md: 9dd8dadd07b219c7471c8851262958d4d9e96a43 -README.zh.md: 5716f56d988c6d2dd9cd237346c3b02ec9ae7c4e +README.md: 95716446c01c7fd510cdf55a82509b5b8af6f3ae +README.zh.md: 0d30b3122265d9bb3caa289345f843fe67377be3 diff --git a/packages/typert/type-meta/README.md b/packages/typert/type-meta/README.md index 9dd8dadd07..95716446c0 100644 --- a/packages/typert/type-meta/README.md +++ b/packages/typert/type-meta/README.md @@ -11,6 +11,8 @@ Compiler-independent declarations shared by business packages, generated TypeRT - `bindTypeRTGateway(this, serviceKey, options?)` creates the visible, frozen binding between a Service instance, its exact Cordis key, and its wire namespace. - `remoteMethods(service)` returns a detached declaration-order snapshot used by the Gateway's SRC fallback. +A Host method opts into cooperative cancellation by declaring `signal: AbortSignal` as its final parameter. `InvocationDescriptor.cancellation` records that reserved injection point; the signal never becomes a JSON parameter or lookup field. SRC recognizes the final parameter name, while strict generation also verifies the global `AbortSignal` type. + Decorator initializers retain markers in a module-private `WeakMap` keyed by the Service prototype. They do not add constructor symbols, prototype properties, parameter metadata, or runtime reflection fields. The Service opts in explicitly through its `typertGateway` binding field. ## TypeRT protocol diff --git a/packages/typert/type-meta/README.zh.md b/packages/typert/type-meta/README.zh.md index 5716f56d98..0d30b31222 100644 --- a/packages/typert/type-meta/README.zh.md +++ b/packages/typert/type-meta/README.zh.md @@ -11,6 +11,8 @@ - `bindTypeRTGateway(this, serviceKey, options?)` 在服务实例、其准确的 Cordis key 与协议命名空间之间创建可见且冻结的绑定。 - `remoteMethods(service)` 返回按声明顺序排列、与内部状态分离的快照,供 Gateway 的 SRC 回退路径使用。 +Host 方法通过将 `signal: AbortSignal` 声明为最后一个参数来启用协作式取消。`InvocationDescriptor.cancellation` 记录这个保留的注入点;signal 绝不会成为 JSON 参数或 lookup 字段。SRC 识别末位参数名,严格生成还会校验它是否具有全局 `AbortSignal` 类型。 + 装饰器初始化器将标记保存在以服务 prototype 为键的模块私有 `WeakMap` 中。它们不会在构造函数上添加 symbol,也不会添加 prototype 属性、参数元数据或运行时反射字段。服务通过自身的 `typertGateway` 绑定字段显式接入。 ## TypeRT 协议 diff --git a/packages/typert/type-meta/src/types.ts b/packages/typert/type-meta/src/types.ts index f9ed7ffa97..6de5c7f823 100644 --- a/packages/typert/type-meta/src/types.ts +++ b/packages/typert/type-meta/src/types.ts @@ -157,6 +157,11 @@ export interface InvocationDescriptor { } /** Ordered business parameters. */ readonly parameters: readonly InvocationParameterDescriptor[] + /** Transport cancellation injected after business parameters instead of entering wire args. */ + readonly cancellation?: { + /** Reserved final Host method parameter. */ + readonly parameter: 'signal' + } /** Codec for the resolved method result. */ readonly result: TypeRTCodec /** Source declaration used only for diagnostics. */ From 1ea5507bf893cae71de68da12d534d6d5dca6d03 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:43:31 +0800 Subject: [PATCH 069/104] fix(typert): close remote gateway review gaps --- ...08-02-typert-remote-method-calls.i18n.yaml | 4 +- .../2026-08-02-typert-remote-method-calls.md | 2 +- ...026-08-02-typert-remote-method-calls.zh.md | 2 +- packages/goal/goal/tests/goal.spec.ts | 16 ++++++++ .../host/api-gateway/tests/gateway.spec.ts | 12 ++++-- packages/typert/registry/src/service.ts | 3 +- scripts/run-gates.ts | 1 + vitest.config.ts | 30 +++------------ vitest.e2e.config.ts | 4 +- vitest.shared.ts | 37 +++++++++++++++++++ vitest.snapshot.config.ts | 4 +- 11 files changed, 77 insertions(+), 38 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index bd83c38a3e..57e1054dfc 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: 4268539ecf0d40a9e8080e0571992cc2c5d724af -2026-08-02-typert-remote-method-calls.zh.md: f9f426f2fb80c74cb9ebaef15e801ccfcf67e027 +2026-08-02-typert-remote-method-calls.md: 552e910b403312c7c7a1cec3a14c0dc1f9cc4380 +2026-08-02-typert-remote-method-calls.zh.md: 18b8c1687d2c01aa23bb7cb9402fccf85fec333d diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index 4268539ecf..552e910b40 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -452,7 +452,7 @@ The Gateway registers only its ownership matcher and RPC handler with Connection The shipped vertical path is `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`. The same direct descriptor with an Agent lookup supports both `ctx.api.goals.create(agentId, request)` and `agentCtx.goals.create(request)`. `@RemoteContext('agent')` remains the distinct scoped-receiver mode. -Connection supplies the shared-channel interceptor and current HTTP carrier mapping. WebSocket migration, the TUI runtime and carrier, TUI Agent Scope wiring, Permission/Approval state machines, Session event streams, call authorization, cancellation, retries, idempotency, and cross-version protocol compatibility remain outside this decision. +Connection supplies the shared-channel interceptor and current HTTP carrier mapping. WebSocket migration, the TUI runtime and carrier, TUI Agent Scope wiring, Permission/Approval state machines, Session event streams, call authorization, retries, idempotency, and cross-version protocol compatibility remain outside this decision. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index f9f426f2fb..18b8c1687d 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -452,7 +452,7 @@ Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 H 已交付的纵向链路是 `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`。同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.api.goals.create(agentId, request)` 与 `agentCtx.goals.create(request)`。`@RemoteContext('agent')` 仍是独立的 scoped receiver 模式。 -Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebSocket 迁移、TUI runtime 与 carrier、TUI Agent Scope 接线、Permission/Approval 状态机、Session 事件流、调用授权、取消、重试、幂等及跨版本协议兼容均不属于本决策。 +Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebSocket 迁移、TUI runtime 与 carrier、TUI Agent Scope 接线、Permission/Approval 状态机、Session 事件流、调用授权、重试、幂等及跨版本协议兼容均不属于本决策。 ## Alternatives considered diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 38eea7cf61..2dd5885cc7 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -245,6 +245,22 @@ describe('GoalService creation and replay', () => { }) describe('GoalService mutations', () => { + it('exposes the supported mutation sequence through Remote wrappers', async () => { + const { ctx, agent } = await harness() + const created = ctx.goals.remoteExportCreate(agent, { objective: 'remote lifecycle' }) + const edited = ctx.goals.remoteExportEdit(agent, created.ref, { objective: 'edited remotely' }) + const paused = ctx.goals.remoteExportPause(agent, edited) + const resumed = ctx.goals.remoteExportResume(agent, paused) + const completed = ctx.goals.remoteExportComplete(agent, resumed) + const cleared = ctx.goals.remoteExportClear(agent, completed) + + expect(edited).toMatchObject({ objective: 'edited remotely', revision: 2 }) + expect(paused).toMatchObject({ phase: 'paused', revision: 3 }) + expect(resumed).toMatchObject({ phase: 'active', revision: 4 }) + expect(completed).toMatchObject({ phase: 'complete', revision: 5 }) + expect(cleared).toEqual({ id: created.ref.id, revision: 6 }) + }) + it('edits with compare-and-set revisions and rejects empty edits', async () => { const { ctx, agent } = await harness() const created = ctx.goals.create(agent, { objective: 'old', maxGoalRounds: 4 }) diff --git a/packages/host/api-gateway/tests/gateway.spec.ts b/packages/host/api-gateway/tests/gateway.spec.ts index c05bfefb93..6558a7ca47 100644 --- a/packages/host/api-gateway/tests/gateway.spec.ts +++ b/packages/host/api-gateway/tests/gateway.spec.ts @@ -998,14 +998,16 @@ describe('TypertGatewayService', () => { }), }) expect(invalid.status).toBe(200) - await expect(invalid.json()).resolves.toMatchObject({ + const invalidBody = await invalid.json() as unknown + expect(invalidBody).toMatchObject({ type: 'server-response', rpcId: 'rpc-invalid', result: { ok: false, - error: { code: 'internal', message: expect.stringContaining('plain-object args field') }, + error: { code: 'internal' }, }, }) + expect(JSON.stringify(invalidBody)).toContain('plain-object args field') await removeStrict() strictActive = false @@ -1020,14 +1022,16 @@ describe('TypertGatewayService', () => { }), }) expect(withdrawn.status).toBe(200) - await expect(withdrawn.json()).resolves.toMatchObject({ + const withdrawnBody = await withdrawn.json() as unknown + expect(withdrawnBody).toMatchObject({ type: 'server-response', rpcId: 'rpc-withdrawn', result: { ok: false, - error: { code: 'internal', message: expect.stringContaining('strict definition was withdrawn') }, + error: { code: 'internal' }, }, }) + expect(JSON.stringify(withdrawnBody)).toContain('strict definition was withdrawn') const unclaimed = await fetch(`${server.origin}/api/legacy/list`, { method: 'POST' }) expect(unclaimed.status).toBe(404) diff --git a/packages/typert/registry/src/service.ts b/packages/typert/registry/src/service.ts index 229dc7affc..d04f38cde5 100644 --- a/packages/typert/registry/src/service.ts +++ b/packages/typert/registry/src/service.ts @@ -562,7 +562,8 @@ function validateInvocation(descriptor: InvocationDescriptor): void { } validateCodec(parameter.codec, `${descriptor.id} parameter ${parameter.name}`) } - if (descriptor.cancellation !== undefined && descriptor.cancellation.parameter !== 'signal') { + const cancellation = descriptor.cancellation as { readonly parameter: string } | undefined + if (cancellation !== undefined && cancellation.parameter !== 'signal') { throw new Error(`typert: invocation "${descriptor.id}" cancellation parameter must be "signal"`) } if (descriptor.scope !== undefined) { diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index f7669eac7e..6d6a76e476 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -601,6 +601,7 @@ function builtBinSmokeGate(needs: string[] = ['build']): Gate { 'packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts', 'packages/subagent/subagent-codex/tests/loader-composition.e2e.ts', 'packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts', + 'packages/client/remotes/tests/built-lib.e2e.ts', // The worker-entry packages' built bundles: the only automated proof // that lib/index.js resolves its sibling lib/worker.cjs under plain node // (the e2e lane runs unbuilt, so these files self-skip there). diff --git a/vitest.config.ts b/vitest.config.ts index 4c4c668b94..56a5a1575b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,8 +3,7 @@ import { fileURLToPath } from 'node:url' import tsconfigPaths from 'vite-tsconfig-paths' import { resolvePwshPath } from './packages/bash/pwsh-local/src/resolve.ts' import { defineConfig } from 'vitest/config' -import ts from 'typescript' -import { vitestExecArgv } from './vitest.shared.ts' +import { standardDecoratorPlugin, vitestExecArgv } from './vitest.shared.ts' import { COVERAGE_EXEMPT_ENV, coverageExemptHeavySuites } from './scripts/coverage-exempt.ts' // Prints exact `path:line:col` records for every uncovered statement, branch @@ -18,29 +17,6 @@ const uncoveredLocationsReporter = fileURLToPath(new URL('./scripts/coverage-unc // map applies to every test file. paths must win over package exports so built // lib/ never loads a second module-singleton copy. const pathsPlugin = (): ReturnType => tsconfigPaths({ projects: ['./tsconfig.base.json'] }) -const decoratorSyntax = /^\s*@[A-Za-z_$][\w$]*/m - -const standardDecoratorPlugin = () => ({ - name: 'dsh-standard-decorators', - enforce: 'pre' as const, - transform(code: string, id: string) { - const file = id.split('?', 1)[0]! - if (!/\.[cm]?tsx?$/.test(file) || !decoratorSyntax.test(code)) return - const result = ts.transpileModule(code, { - fileName: file, - compilerOptions: { - target: ts.ScriptTarget.ES2024, - module: ts.ModuleKind.ESNext, - jsx: file.endsWith('x') ? ts.JsxEmit.ReactJSX : undefined, - sourceMap: true, - }, - }) - return { - code: result.outputText.replace(/\n?\/\/# sourceMappingURL=.*$/u, '\n'), - map: result.sourceMapText, - } - }, -}) const windowsUnsupportedPackages = process.platform === 'win32' ? [ @@ -203,6 +179,10 @@ export default defineConfig({ 'packages/client/hmr/src/invariant.ts', 'packages/client/connection/src/index.ts', 'packages/client/connection/src/http-bridge.ts', + // This assembly imports generated Host-for-Client code that exists + // only in lib; the post-build built-bin smoke executes both entries. + 'packages/client/remotes/src/index.ts', + 'packages/client/remotes/src/client/index.ts', // Slash/command/input round: per-file gaps deferred with the same // client-lane debt. TODO(gui): cover and remove with the lane above. 'packages/client/connection/src/client/fixture.ts', diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index d8e6aa53a7..f898d2d9da 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -1,6 +1,6 @@ import tsconfigPaths from 'vite-tsconfig-paths' import { defineConfig } from 'vitest/config' -import { vitestExecArgv } from './vitest.shared.ts' +import { standardDecoratorPlugin, vitestExecArgv } from './vitest.shared.ts' // Real-API suite, separate because it spends tokens. Each test self-skips without // its provider credential for keyless CI; credentialed workflows preflight the @@ -36,7 +36,7 @@ export default defineConfig({ // Built-artifact e2e suites are unaffected: their built-ness lives in // subprocesses and createRequire lookups, which bypass vite resolution // entirely. - plugins: [tsconfigPaths({ projects: ['./tsconfig.base.json'] })], + plugins: [tsconfigPaths({ projects: ['./tsconfig.base.json'] }), standardDecoratorPlugin()], test: { execArgv: vitestExecArgv, setupFiles: ['./scripts/test-invariants.ts'], diff --git a/vitest.shared.ts b/vitest.shared.ts index 506fabb380..7c6ca2bee8 100644 --- a/vitest.shared.ts +++ b/vitest.shared.ts @@ -1,5 +1,42 @@ +import ts from 'typescript' + +const decoratorSyntax = /^\s*@[A-Za-z_$][\w$]*/m + /** * Worker arguments that keep process-wide Web Storage from shadowing jsdom storage. * Node lists the positive spelling in `allowedNodeEnvironmentFlags` for this negatable flag. */ export const vitestExecArgv = process.allowedNodeEnvironmentFlags.has('--webstorage') ? ['--no-webstorage'] : [] + +/** + * Transform standard TypeScript decorators before Vite's default parser sees source files. + * @returns a pre-transform Vite plugin shared by source-mode test configurations. + */ +export function standardDecoratorPlugin() { + return { + name: 'dsh-standard-decorators', + enforce: 'pre' as const, + transform(code: string, id: string) { + const file = id.split('?', 1)[0]! + if (!/\.[cm]?tsx?$/.test(file) || !decoratorSyntax.test(code)) return + const result = ts.transpileModule(code, { + fileName: file, + compilerOptions: { + target: ts.ScriptTarget.ES2024, + module: ts.ModuleKind.ESNext, + jsx: file.endsWith('x') ? ts.JsxEmit.ReactJSX : undefined, + sourceMap: true, + }, + }) + return { + code: result.outputText + .replace( + /^(\s*)(__esDecorate\()/gmu, + '$1/* v8 ignore next -- compiler-synthetic decorator accessors have no source behavior */ $2', + ) + .replace(/\n?\/\/# sourceMappingURL=.*$/u, '\n'), + map: result.sourceMapText, + } + }, + } +} diff --git a/vitest.snapshot.config.ts b/vitest.snapshot.config.ts index 455ecfb4d4..cfa7d12e17 100644 --- a/vitest.snapshot.config.ts +++ b/vitest.snapshot.config.ts @@ -1,7 +1,7 @@ import { availableParallelism } from 'node:os' import tsconfigPaths from 'vite-tsconfig-paths' import { defineConfig } from 'vitest/config' -import { vitestExecArgv } from './vitest.shared.ts' +import { standardDecoratorPlugin, vitestExecArgv } from './vitest.shared.ts' const DEFAULT_SNAPSHOT_MAX_CONCURRENCY = 5 @@ -40,7 +40,7 @@ export default defineConfig({ // Same resolution note as vitest.config.ts: bare workspace names resolve // through the tsconfig.base.json paths facade; the native option cannot do // this (the root tsconfig is a solution file with no paths). - plugins: [tsconfigPaths({ projects: ['./tsconfig.base.json'] })], + plugins: [tsconfigPaths({ projects: ['./tsconfig.base.json'] }), standardDecoratorPlugin()], test: { execArgv: vitestExecArgv, setupFiles: ['./scripts/test-invariants.ts'], From e8f2ab89bb98c81374f570386793173f4c718aa2 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:04:25 +0800 Subject: [PATCH 070/104] refactor(typert): bind remote services through base class --- ...08-02-typert-remote-method-calls.i18n.yaml | 4 +- .../2026-08-02-typert-remote-method-calls.md | 24 ++++---- ...026-08-02-typert-remote-method-calls.zh.md | 24 ++++---- packages/goal/goal/src/index.ts | 9 +-- packages/host/api-gateway/README.i18n.yaml | 4 +- packages/host/api-gateway/README.md | 2 +- packages/host/api-gateway/README.zh.md | 2 +- packages/typert/generator/src/analyzer.ts | 50 ++++++++++++++-- .../remote-model/packages/remote/src/index.ts | 8 ++- .../fixtures/remote-model/type-meta.d.ts | 13 ++++ .../generator/tests/remote-model.spec.ts | 60 +++++++++++++++++-- packages/typert/type-meta/README.i18n.yaml | 4 +- packages/typert/type-meta/README.md | 7 ++- packages/typert/type-meta/README.zh.md | 7 ++- packages/typert/type-meta/src/index.ts | 18 ++++++ .../type-meta/tests/fixtures/source-launch.ts | 11 ++-- .../typert/type-meta/tests/type-meta.spec.ts | 27 +++++++-- 17 files changed, 213 insertions(+), 61 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index 57e1054dfc..3808a8d363 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: 552e910b403312c7c7a1cec3a14c0dc1f9cc4380 -2026-08-02-typert-remote-method-calls.zh.md: 18b8c1687d2c01aa23bb7cb9402fccf85fec333d +2026-08-02-typert-remote-method-calls.md: ade8eb827ae765677be8dcdb0ffec965c67bc4ab +2026-08-02-typert-remote-method-calls.zh.md: 2de887a2a0e46148fbb2b5ac52cfd7e3b2305b8d diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index 552e910b40..ade8eb827a 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -16,7 +16,7 @@ The Host and Browser Client use separate TypeScript Programs because each side a ## Decision -A business Service declares callable methods with `@Remote` or `@RemoteContext()` and explicitly joins the Gateway through `bindTypeRTGateway()`. TypeRT generates the Host-local reflection artifact and a platform-independent Remote consumer projection from the Host Program. The Client Program continues to generate its own local reflection artifact independently. +A business Service extends `GatewayService` and declares callable methods with `@Remote` or `@RemoteContext()`. A Service that already has another base class may instead expose the same binding through `bindTypeRTGateway()`. TypeRT generates the Host-local reflection artifact and a platform-independent Remote consumer projection from the Host Program. The Client Program continues to generate its own local reflection artifact independently. The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. The `.d.ts` exposes only methods marked with a Remote decorator and refers to the business package's single public type symbols. The `.d.ts.map` navigates consumer API methods back to their Host business method implementations. The `.js` carries endpoint, parameter, Context, and Zod information for the same contract. At the assembly layer, the Browser Client mounts the required Remote JS contributions onto the Client API Service. The projection and API abstraction remain platform-independent so that a future TUI can reuse them. @@ -26,7 +26,7 @@ The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. T | Component | Cordis service | Responsibility | |---|---|---| -| `@deepseek-ai/dsh-type-meta` | Declares only the minimal `ctx.typert` protocol | Decorators, bindings, descriptors, lookup/Context, and the Remote map; no dependency on the compiler, Zod, Connection, or Browser | +| `@deepseek-ai/dsh-type-meta` | Declares only the minimal `ctx.typert` protocol | `GatewayService`, decorators, binding fallback, descriptors, lookup/Context, and the Remote map; no dependency on the compiler, Zod, Connection, or Browser | | TypeRT registry | `ctx.typert` | Separately stores reflection for the current environment, imported Remote contributions, lookup providers, and Context providers | | TypeRT generator/loader | No new business service | Generates three kinds of `lib` artifacts from the Host/Client Programs and registers the current environment's artifacts with `ctx.typert` | | Host API Gateway's Host face | `ctx.typertGateway` | Associates Host definitions with live Services, decodes parameters, resolves receivers, invokes methods, and encodes results | @@ -43,8 +43,10 @@ The Host Gateway does not depend on concrete implementations of `ctx.agents`, `c Ordinary direct calls use `@Remote`. When migrating to an existing Service or Registry, do not rename or alter existing methods. Add `remoteExport*` entry points at the end of the class and use decorator arguments to declare their short API names. A method explicitly declares every required business object in a top-level parameter position: ```text -export class GoalService extends Service { - readonly typertGateway = bindTypeRTGateway(this, 'goals') +export class GoalService extends GatewayService { + constructor(ctx: Context) { + super(ctx, 'goals') + } create(agent: Agent, request: CreateGoalRequest): CreateGoalResult { // Existing business method remains unchanged. @@ -57,13 +59,15 @@ export class GoalService extends Service { } ``` -`goals` is an explicit Cordis service key and is the default wire namespace. Override it through an option to `bindTypeRTGateway()` only when the protocol namespace genuinely needs to differ from the service key. +`goals` is the explicit Cordis service key passed to `super()` and is the default wire namespace. Pass a `namespace` option as the third argument only when the protocol namespace genuinely needs to differ from the service key. Use `@RemoteContext()` when the Service receiver must be resolved within an isolated kind of Context. Context identity does not enter the business method's parameters: ```text -export class ScopedGoalService extends Service { - readonly typertGateway = bindTypeRTGateway(this, 'goals') +export class ScopedGoalService extends GatewayService { + constructor(ctx: Context) { + super(ctx, 'goals') + } @RemoteContext('agent', 'create') remoteExportCreate(request: CreateGoalRequest): Promise { @@ -74,17 +78,17 @@ export class ScopedGoalService extends Service { An endpoint selects exactly one invocation mode. A flow that needs an explicit `Agent` parameter uses `@Remote`. A flow that first switches to an Agent Context and then resolves a scoped receiver uses `@RemoteContext('agent')`. TypeRT does not infer either mode from the method body or from a missing parameter. -Business packages depend only on the lightweight `@deepseek-ai/dsh-type-meta`. It provides declaration protocols for decorators, `bindTypeRTGateway()`, lookup, Remote Context, and descriptors, without depending on the TypeScript compiler, Zod, HTTP, or the Client runtime. +Business packages depend only on the lightweight `@deepseek-ai/dsh-type-meta`. It provides `GatewayService` and declaration protocols for decorators, the binding fallback, lookup, Remote Context, and descriptors, without depending on the TypeScript compiler, Zod, HTTP, or the Client runtime. A method that cooperatively supports cancellation declares `signal: AbortSignal` as its final Host parameter. This reserved parameter is not a business value, lookup, or JSON field. The generated consumer method exposes it as a final optional parameter so ordinary calls remain unchanged while callers that own cancellation can pass a signal. ## Decorators and the explicit Gateway facet -A decorator only states that a method participates in the Remote contract. It performs no runtime type reflection and injects no hidden symbol into a Service constructor. The arguments to `@Remote('create')` and `@RemoteContext('agent', 'create')` are external method names, while the actual member remains named `remoteExportCreate`. The member name becomes the external method name only when no alias is provided. `typertGateway` is the sole explicit marker that a Service has joined the Gateway, making this capability visible on both the business class and its runtime instance. +A decorator only states that a method participates in the Remote contract. It performs no runtime type reflection and injects no hidden symbol into a Service constructor. The arguments to `@Remote('create')` and `@RemoteContext('agent', 'create')` are external method names, while the actual member remains named `remoteExportCreate`. The member name becomes the external method name only when no alias is provided. Inheriting `GatewayService` is the normal explicit declaration that a Service has joined the Gateway; its public readonly `typertGateway` field keeps the binding visible on the runtime instance. In SRC mode, the decorator may record the prototype, method name, and invocation mode in a `WeakMap` internal to `dsh-type-meta`. It writes no custom properties to a Service instance, prototype, constructor, or method function. -In LIB mode, the TypeRT compiler performs strict method discovery, type resolution, and descriptor generation. Generation neither rewrites business source nor secretly supplies generated arguments to `bindTypeRTGateway()`. +In LIB mode, the TypeRT compiler performs strict method discovery, type resolution, and descriptor generation. It accepts a literal service key in `GatewayService`'s direct `super()` call or the explicit binding fallback; generation neither rewrites business source nor injects hidden registration metadata. ## Lookup and Remote Context registration diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index 18b8c1687d..2de887a2a0 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -16,7 +16,7 @@ Host 与 Browser Client 使用独立的 TypeScript Program,因为两边会以 ## 决策 -业务 Service 通过 `@Remote` 或 `@RemoteContext()` 声明可调用方法,并通过 `bindTypeRTGateway()` 显式加入 Gateway。TypeRT 从 Host Program 生成 Host 本地反射产物和平台无关的 Remote 消费端投影;Client Program 继续独立生成自己的本地反射产物。 +业务 Service 继承 `GatewayService`,并通过 `@Remote` 或 `@RemoteContext()` 声明可调用方法;已有其他基类的 Service 可以改用 `bindTypeRTGateway()` 暴露同一绑定。TypeRT 从 Host Program 生成 Host 本地反射产物和平台无关的 Remote 消费端投影;Client Program 继续独立生成自己的本地反射产物。 Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只暴露被 Remote decorator 标记的方法,并引用业务包唯一的公共类型符号;`.d.ts.map` 把消费端 API 方法导航回 Host 业务方法实现;`.js` 携带同一契约的 endpoint、参数、Context 和 Zod 信息。Browser Client 在 assembly 层把需要的 Remote JS 贡献集中挂到 Client API Service;该投影和 API 抽象保持平台无关,以便未来 TUI 复用。 @@ -26,7 +26,7 @@ Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只 | 组件 | Cordis 服务 | 职责 | |---|---|---| -| `@deepseek-ai/dsh-type-meta` | 只声明 `ctx.typert` 的最小协议 | decorator、binding、descriptor、lookup/Context 和 Remote map;不依赖 compiler、Zod、Connection 或 Browser | +| `@deepseek-ai/dsh-type-meta` | 只声明 `ctx.typert` 的最小协议 | `GatewayService`、decorator、binding 回退、descriptor、lookup/Context 和 Remote map;不依赖 compiler、Zod、Connection 或 Browser | | TypeRT registry | `ctx.typert` | 分开保存当前环境 reflection、导入的 Remote contribution、lookup provider 和 Context provider | | TypeRT generator/loader | 无新增业务服务 | 从 Host/Client Program 生成三类 `lib` 产物,并把当前环境产物注册到 `ctx.typert` | | Host API Gateway 的 Host face | `ctx.typertGateway` | 关联 Host definition 与活 Service,解码参数、解析 receiver、调用方法和编码结果 | @@ -43,8 +43,10 @@ Host Gateway 不依赖 `ctx.agents`、`ctx.sessions`、`ctx.goals` 或 `ctx.http 普通直接调用使用 `@Remote`。迁移到现存 Service 或 Registry 时不重命名、不改变存量方法;类末尾新增 `remoteExport*` 出口,并由 decorator 参数声明短 API 名。方法需要哪个业务对象,就在顶层参数位置显式声明该对象: ```text -export class GoalService extends Service { - readonly typertGateway = bindTypeRTGateway(this, 'goals') +export class GoalService extends GatewayService { + constructor(ctx: Context) { + super(ctx, 'goals') + } create(agent: Agent, request: CreateGoalRequest): CreateGoalResult { // Existing business method remains unchanged. @@ -57,13 +59,15 @@ export class GoalService extends Service { } ``` -`goals` 是明确的 Cordis service key,并默认作为 wire namespace。只有协议 namespace 确实需要与 service key 不同时,才通过 `bindTypeRTGateway()` 的选项覆盖。 +`goals` 是传给 `super()` 的明确 Cordis service key,并默认作为 wire namespace。只有协议 namespace 确实需要与 service key 不同时,才通过第三个参数传入 `namespace` 选项。 需要在某类隔离 Context 中查找 Service receiver 时使用 `@RemoteContext()`。Context identity 不进入业务方法参数: ```text -export class ScopedGoalService extends Service { - readonly typertGateway = bindTypeRTGateway(this, 'goals') +export class ScopedGoalService extends GatewayService { + constructor(ctx: Context) { + super(ctx, 'goals') + } @RemoteContext('agent', 'create') remoteExportCreate(request: CreateGoalRequest): Promise { @@ -74,17 +78,17 @@ export class ScopedGoalService extends Service { 同一个 endpoint 只能选择一种调用模式。需要显式 `Agent` 参数的流程使用 `@Remote`;需要切换到 Agent Context 再解析 scoped receiver 的流程使用 `@RemoteContext('agent')`,两者不会由 TypeRT 根据方法体或参数缺失自动猜测。 -业务包只依赖轻量的 `@deepseek-ai/dsh-type-meta`。它提供 decorator、`bindTypeRTGateway()`、lookup、Remote Context 和 descriptor 的声明协议,不依赖 TypeScript compiler、Zod、HTTP 或 Client runtime。 +业务包只依赖轻量的 `@deepseek-ai/dsh-type-meta`。它提供 `GatewayService`,以及 decorator、binding 回退、lookup、Remote Context 和 descriptor 的声明协议,不依赖 TypeScript compiler、Zod、HTTP 或 Client runtime。 支持协作式取消的方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。这个保留参数不是业务值、lookup 或 JSON 字段。生成的消费方方法将其暴露为最后一个可选参数,因此普通调用保持不变,而拥有取消控制权的调用方可以传入 signal。 ## Decorator 与显式 Gateway facet -Decorator 只表达“该方法参与 Remote 契约”,不负责运行时类型反射,也不向 Service constructor 注入隐藏 symbol。`@Remote('create')` 和 `@RemoteContext('agent', 'create')` 的参数是外部方法名,实际成员名保持 `remoteExportCreate`;未给别名时才使用成员名作为外部方法名。`typertGateway` 是 Service 加入 Gateway 的唯一显式标志,使业务类和运行时实例都能直接看出这项能力。 +Decorator 只表达“该方法参与 Remote 契约”,不负责运行时类型反射,也不向 Service constructor 注入隐藏 symbol。`@Remote('create')` 和 `@RemoteContext('agent', 'create')` 的参数是外部方法名,实际成员名保持 `remoteExportCreate`;未给别名时才使用成员名作为外部方法名。继承 `GatewayService` 是 Service 加入 Gateway 的常规显式声明;其 public readonly `typertGateway` 字段使运行时实例上的绑定保持可见。 SRC 运行时允许 decorator 在 `dsh-type-meta` 内部的 `WeakMap` 记录 prototype、方法名和调用模式。它不向 Service 实例、prototype、constructor 或方法函数写入自定义属性。 -LIB 的严格方法发现、类型解析和 descriptor 生成由 TypeRT compiler 完成。生成过程不改写业务源码,也不向 `bindTypeRTGateway()` 偷注生成参数。 +LIB 的严格方法发现、类型解析和 descriptor 生成由 TypeRT compiler 完成。它接受 `GatewayService` 直接 `super()` 调用中的字面量 service key,或显式 binding 回退;生成过程不改写业务源码,也不注入隐藏注册元数据。 ## Lookup 与 Remote Context 注册 diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index 0997aad0dc..312e3a70d9 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -5,14 +5,14 @@ */ import { randomUUID } from 'node:crypto' -import { Context, Service } from 'cordis' +import { Context } from 'cordis' import z from 'schemastery' import { z as zod } from 'zod' import type { ZodType } from 'zod' import { agentEvents } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' -import { Remote, bindTypeRTGateway } from '@deepseek-ai/dsh-type-meta' +import { GatewayService, Remote } from '@deepseek-ai/dsh-type-meta' // Type-only: resolves ctx.sessionProjections for the optional unit child. import type {} from '@deepseek-ai/dsh-session-projection' import { @@ -180,7 +180,7 @@ function resolveBlockReason(reason: unknown): GoalBlockReason { } /** Goal service (`ctx.goals`) backed exclusively by the owning session log. */ -export class GoalService extends Service { +export class GoalService extends GatewayService { static inject = ['agents'] static Config: z = z.object({ @@ -190,9 +190,6 @@ export class GoalService extends Service { private readonly resolved: ResolvedConfig private readonly caches = new WeakMap() - /** Explicit participation in the TypeRT Gateway under the Cordis service key. */ - readonly typertGateway = bindTypeRTGateway(this, 'goals') - constructor(ctx: Context, config: Config = {}) { super(ctx, 'goals') this.resolved = { diff --git a/packages/host/api-gateway/README.i18n.yaml b/packages/host/api-gateway/README.i18n.yaml index a1c22433f3..273a493c24 100644 --- a/packages/host/api-gateway/README.i18n.yaml +++ b/packages/host/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/host/api-gateway/README.md -README.md: 9cb6e7e1c0a23789ab4ab2c999b5a6c2d4cd32f9 -README.zh.md: 609580ceb77649ba8df6103093a72092c9ccc8a1 +README.md: 43e8f464e2a2790d05628a7fba61143a6a5ab26a +README.zh.md: 761045d0c1afc17dfc230f9f45849c46e4e579fc diff --git a/packages/host/api-gateway/README.md b/packages/host/api-gateway/README.md index 9cb6e7e1c0..43e8f464e2 100644 --- a/packages/host/api-gateway/README.md +++ b/packages/host/api-gateway/README.md @@ -6,7 +6,7 @@ Two-sided Remote control for Host and Client Cordis environments. The Host entry ## Host service: `TypertGatewayService` (ctx key: `typertGateway`) -`ctx.typertGateway.invoke()` resolves the current descriptor and Cordis Service for each call, validates exact named arguments, resolves registered object or Context identities, invokes the public business method, and validates its result. Business Services declare participation with `bindTypeRTGateway()` and `@Remote` or `@RemoteContext` from [`dsh-type-meta`](../../typert/type-meta/README.md). +`ctx.typertGateway.invoke()` resolves the current descriptor and Cordis Service for each call, validates exact named arguments, resolves registered object or Context identities, invokes the public business method, and validates its result. Business Services extend `GatewayService` and mark methods with `@Remote` or `@RemoteContext` from [`dsh-type-meta`](../../typert/type-meta/README.md); `bindTypeRTGateway()` remains available when another base class owns inheritance. Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use registered `ctx.typert.lookups` providers, while `@RemoteContext` resolves its receiver through a registered Host Context provider. 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. diff --git a/packages/host/api-gateway/README.zh.md b/packages/host/api-gateway/README.zh.md index 609580ceb7..761045d0c1 100644 --- a/packages/host/api-gateway/README.zh.md +++ b/packages/host/api-gateway/README.zh.md @@ -6,7 +6,7 @@ ## Host 服务:`TypertGatewayService`(ctx key:`typertGateway`) -每次调用时,`ctx.typertGateway.invoke()` 都会解析当前的描述符和 Cordis 服务,校验具名参数是否完全匹配,解析已注册的对象或 Context 身份标识,调用公开的业务方法,并校验其结果。业务服务调用 `bindTypeRTGateway()` 并使用 [`dsh-type-meta`](../../typert/type-meta/README.md) 提供的 `@Remote` 或 `@RemoteContext` 装饰器,以显式声明接入。 +每次调用时,`ctx.typertGateway.invoke()` 都会解析当前的描述符和 Cordis 服务,校验具名参数是否完全匹配,解析已注册的对象或 Context 身份标识,调用公开的业务方法,并校验其结果。业务服务继承 [`dsh-type-meta`](../../typert/type-meta/README.md) 的 `GatewayService`,并用 `@Remote` 或 `@RemoteContext` 标记方法;已有其他基类时仍可改用 `bindTypeRTGateway()`。 严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用已向 `ctx.typert.lookups` 注册的提供方,`@RemoteContext` 则通过已注册的 Host Context 提供方解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。 diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index 87a23f17f5..ecc7d8aa6b 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -142,7 +142,7 @@ interface StaticContextDeclaration { interface GatewayBinding { readonly service: string readonly namespace: string - readonly site: ts.PropertyDeclaration + readonly site: ts.Node } type ReferenceSite = ts.TypeReferenceNode | ts.ExpressionWithTypeArguments | ts.ImportTypeNode @@ -927,7 +927,10 @@ class FaceAnalyzer { if (first === undefined) continue const binding = this.gatewayBinding(statement) if (binding === undefined) { - this.fail(first.method, 'Remote methods require readonly typertGateway = bindTypeRTGateway(this, serviceKey)') + this.fail( + first.method, + 'Remote methods require GatewayService or readonly typertGateway = bindTypeRTGateway(this, serviceKey)', + ) } for (const { method, invocation } of marked) { result.push(this.invocationModel(registration, binding, method, invocation)) @@ -1089,6 +1092,15 @@ class FaceAnalyzer { } private gatewayBinding(declaration: ts.ClassDeclaration): GatewayBinding | undefined { + const field = this.gatewayFieldBinding(declaration) + const base = this.gatewayServiceBinding(declaration) + if (field !== undefined && base !== undefined) { + this.fail(field.site, 'GatewayService subclasses must not declare a second typertGateway binding') + } + return field ?? base + } + + private gatewayFieldBinding(declaration: ts.ClassDeclaration): GatewayBinding | undefined { const candidates = declaration.members.filter((member): member is ts.PropertyDeclaration => ts.isPropertyDeclaration(member) && memberName(member.name) === 'typertGateway') const [property, duplicate] = candidates @@ -1111,10 +1123,38 @@ class FaceAnalyzer { if (call.arguments[0]?.kind !== ts.SyntaxKind.ThisKeyword) { this.fail(call.arguments[0] ?? call, 'bindTypeRTGateway() first argument must be this') } + return this.gatewayBindingArguments(call, property) + } + + private gatewayServiceBinding(declaration: ts.ClassDeclaration): GatewayBinding | undefined { + const heritage = (declaration.heritageClauses ?? []) + .filter(clause => clause.token === ts.SyntaxKind.ExtendsKeyword) + .flatMap(clause => [...clause.types]) + .find(type => this.isTypeMetaSymbol(type.expression, 'GatewayService')) + if (heritage === undefined) return undefined + + const constructor = declaration.members.find(ts.isConstructorDeclaration) + if (constructor?.body === undefined) { + this.fail(heritage, 'GatewayService subclasses must declare a constructor with super(ctx, serviceKey)') + } + const call = constructor.body.statements.flatMap((statement) => { + if (!ts.isExpressionStatement(statement) || !ts.isCallExpression(statement.expression)) return [] + return statement.expression.expression.kind === ts.SyntaxKind.SuperKeyword ? [statement.expression] : [] + })[0] + if (call === undefined) { + this.fail(constructor, 'GatewayService constructor must call super(ctx, serviceKey) directly') + } + if (call.arguments.length < 2 || call.arguments.length > 3) { + this.fail(call, 'GatewayService super() requires context, service key, and an optional options object') + } + return this.gatewayBindingArguments(call, heritage) + } + + private gatewayBindingArguments(call: ts.CallExpression, site: ts.Node): GatewayBinding { const serviceArgument = call.arguments[1] - if (serviceArgument === undefined) this.fail(call, 'bindTypeRTGateway() service key must be a string literal') + if (serviceArgument === undefined) this.fail(call, 'Gateway service key must be a string literal') const service = stringLiteralValue(serviceArgument) - if (service === undefined) this.fail(serviceArgument, 'bindTypeRTGateway() service key must be a string literal') + if (service === undefined) this.fail(serviceArgument, 'Gateway service key must be a string literal') let namespace = service const options = call.arguments[2] if (options !== undefined) { @@ -1133,7 +1173,7 @@ class FaceAnalyzer { } if (!isRemoteSegment(service)) this.fail(serviceArgument, 'Gateway service key must be nonempty and must not contain "/"') if (!isRemoteSegment(namespace)) this.fail(options ?? call, 'Gateway namespace must be nonempty and must not contain "/"') - return { service, namespace, site: property } + return { service, namespace, site } } private remoteMarker( diff --git a/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts index 115b3b87a6..4aa51ec433 100644 --- a/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts +++ b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts @@ -1,4 +1,4 @@ -import { Remote, RemoteContext, bindTypeRTGateway } from '@deepseek-ai/dsh-type-meta' +import { GatewayService, Remote, RemoteContext } from '@deepseek-ai/dsh-type-meta' import type { Agent } from '@fixture/domain' import type { CreateGoalRequest, @@ -8,8 +8,10 @@ import type { } from './types.ts' /** Remote-only business Service with no Cordis declaration merge. */ -export class GoalService { - readonly typertGateway = bindTypeRTGateway(this, 'goals') +export class GoalService extends GatewayService { + constructor() { + super(undefined, 'goals') + } @Remote async create(agent: Agent, request: CreateGoalRequest, signal: AbortSignal): Promise { diff --git a/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts b/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts index f8e84bbe90..91daea98c2 100644 --- a/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts +++ b/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts @@ -26,6 +26,19 @@ declare module '@deepseek-ai/dsh-type-meta' { readonly descriptors: readonly unknown[] } + export abstract class GatewayService { + readonly typertGateway: { + readonly service: GatewayService + readonly serviceKey: string + readonly namespace: string + } + protected constructor( + ctx: unknown, + serviceKey: string, + options?: { readonly namespace?: string }, + ) + } + export function bindTypeRTGateway( service: Service, serviceKey: string, diff --git a/packages/typert/generator/tests/remote-model.spec.ts b/packages/typert/generator/tests/remote-model.spec.ts index d5838f39ce..268645ca73 100644 --- a/packages/typert/generator/tests/remote-model.spec.ts +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -219,8 +219,56 @@ export type GenericResult = { it.each([ { name: 'missing binding', - edit: (source: string) => source.replace(" readonly typertGateway = bindTypeRTGateway(this, 'goals')\n\n", ''), - message: 'Remote methods require readonly typertGateway', + edit: (source: string) => source.replace( + "export class GoalService extends GatewayService {\n constructor() {\n super(undefined, 'goals')\n }", + 'export class GoalService {', + ), + message: 'Remote methods require GatewayService', + }, + { + name: 'dynamic GatewayService key', + edit: (source: string) => source.replace( + " constructor() {\n super(undefined, 'goals')\n }", + ' constructor(serviceKey: string) {\n super(undefined, serviceKey)\n }', + ), + message: 'Gateway service key must be a string literal', + }, + { + name: 'GatewayService without a constructor', + edit: (source: string) => source.replace( + " constructor() {\n super(undefined, 'goals')\n }\n\n", + '', + ), + message: 'GatewayService subclasses must declare a constructor', + }, + { + name: 'GatewayService without a direct super call', + edit: (source: string) => source.replace( + " super(undefined, 'goals')", + ' void undefined', + ), + message: 'GatewayService constructor must call super', + }, + { + name: 'GatewayService super call without a service key', + edit: (source: string) => source.replace( + " super(undefined, 'goals')", + ' super(undefined)', + ), + message: 'GatewayService super\\(\\) requires context, service key', + }, + { + name: 'duplicate GatewayService field binding', + edit: (source: string) => source + .replace( + 'import { GatewayService, Remote, RemoteContext }', + 'import { GatewayService, Remote, RemoteContext, bindTypeRTGateway }', + ) + .replace( + 'export class GoalService extends GatewayService {', + "export class GoalService extends GatewayService {\n readonly typertGateway = bindTypeRTGateway(this, 'goals')", + ), + message: 'GatewayService subclasses must not declare a second typertGateway binding', }, { name: 'private method', @@ -351,8 +399,10 @@ export type GenericResult = { it('rejects duplicate endpoints across Remote services', () => { const root = copyFixture() editFile(root, 'packages/remote/src/index.ts', source => `${source} -export class DuplicateGoalService { - readonly typertGateway = bindTypeRTGateway(this, 'duplicate', { namespace: 'goals' }) +export class DuplicateGoalService extends GatewayService { + constructor() { + super(undefined, 'duplicate', { namespace: 'goals' }) + } @Remote create(request: CreateGoalRequest): CreateGoalResult { @@ -521,7 +571,7 @@ ctx.api.goals.create('agent-1', { title: 'must not compile' }) if (config.error !== undefined) throw new Error(formatDiagnostics([config.error])) const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, consumerRoot, undefined, configPath) const diagnostics = ts.getPreEmitDiagnostics(ts.createProgram(parsed.fileNames, parsed.options)) - expect(diagnostics).toHaveLength(1) + expect(diagnostics, formatDiagnostics(diagnostics)).toHaveLength(1) expect(diagnostics[0]?.code).toBe(2339) expect(ts.flattenDiagnosticMessageText(diagnostics[0]?.messageText ?? '', '\n')).toContain("Property 'goals' does not exist") } diff --git a/packages/typert/type-meta/README.i18n.yaml b/packages/typert/type-meta/README.i18n.yaml index 9751c4c088..a3e0643ace 100644 --- a/packages/typert/type-meta/README.i18n.yaml +++ b/packages/typert/type-meta/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/typert/type-meta/README.md -README.md: 95716446c01c7fd510cdf55a82509b5b8af6f3ae -README.zh.md: 0d30b3122265d9bb3caa289345f843fe67377be3 +README.md: 245df305efcf711486b2d3f32e40a8b415f2682e +README.zh.md: 592aa5d027a52a7a277a90ba5d51f19101f055f6 diff --git a/packages/typert/type-meta/README.md b/packages/typert/type-meta/README.md index 95716446c0..245df305ef 100644 --- a/packages/typert/type-meta/README.md +++ b/packages/typert/type-meta/README.md @@ -2,18 +2,19 @@ English | [中文](README.zh.md) -Compiler-independent declarations shared by business packages, generated TypeRT artifacts, the Host Gateway, and Client API. This package owns Remote decorators, the explicit Service binding, merge-extensible protocol maps, invocation descriptors, codecs, and provider contracts; it does not run TypeScript analysis or provide a Cordis service. +Compiler-independent declarations shared by business packages, generated TypeRT artifacts, the Host Gateway, and Client API. This package owns the Remote Service base, decorators, explicit binding fallback, merge-extensible protocol maps, invocation descriptors, codecs, and provider contracts; it does not run TypeScript analysis or register a concrete Cordis service. ## Remote declarations - `@Remote` marks a public instance method for direct invocation on its registered Cordis Service. - `@RemoteContext(key)` marks a method whose receiver is selected from a merge-declared scoped Context kind. -- `bindTypeRTGateway(this, serviceKey, options?)` creates the visible, frozen binding between a Service instance, its exact Cordis key, and its wire namespace. +- `GatewayService` binds the Cordis key passed to `super(ctx, serviceKey, options?)` to the same default wire namespace. +- `bindTypeRTGateway(this, serviceKey, options?)` provides the same visible, frozen binding for a Service that cannot inherit from `GatewayService`. - `remoteMethods(service)` returns a detached declaration-order snapshot used by the Gateway's SRC fallback. A Host method opts into cooperative cancellation by declaring `signal: AbortSignal` as its final parameter. `InvocationDescriptor.cancellation` records that reserved injection point; the signal never becomes a JSON parameter or lookup field. SRC recognizes the final parameter name, while strict generation also verifies the global `AbortSignal` type. -Decorator initializers retain markers in a module-private `WeakMap` keyed by the Service prototype. They do not add constructor symbols, prototype properties, parameter metadata, or runtime reflection fields. The Service opts in explicitly through its `typertGateway` binding field. +Decorator initializers retain markers in a module-private `WeakMap` keyed by the Service prototype. They do not add constructor symbols, prototype properties, parameter metadata, or runtime reflection fields. A `GatewayService` exposes the same public readonly `typertGateway` binding that the explicit helper returns. ## TypeRT protocol diff --git a/packages/typert/type-meta/README.zh.md b/packages/typert/type-meta/README.zh.md index 0d30b31222..592aa5d027 100644 --- a/packages/typert/type-meta/README.zh.md +++ b/packages/typert/type-meta/README.zh.md @@ -2,18 +2,19 @@ [English](README.md) | 中文 -该包提供不依赖编译器的声明,由业务包、生成的 TypeRT 产物、Host Gateway 和 Client API 共享。它负责 Remote 装饰器、显式服务绑定、可通过声明合并扩展的协议映射、调用描述符、编解码器和提供方契约;它不执行 TypeScript 分析,也不提供 Cordis 服务。 +该包提供不依赖编译器的声明,由业务包、生成的 TypeRT 产物、Host Gateway 和 Client API 共享。它负责 Remote Service 基类、装饰器、显式 binding 回退、可通过声明合并扩展的协议映射、调用描述符、编解码器和提供方契约;它不执行 TypeScript 分析,也不注册具体 Cordis 服务。 ## Remote 声明 - `@Remote` 将公开实例方法标记为可在其注册的 Cordis 服务上直接调用。 - `@RemoteContext(key)` 标记接收者选自合并声明的作用域 Context 类型的方法。 -- `bindTypeRTGateway(this, serviceKey, options?)` 在服务实例、其准确的 Cordis key 与协议命名空间之间创建可见且冻结的绑定。 +- `GatewayService` 将 `super(ctx, serviceKey, options?)` 接收的 Cordis key 同时绑定为默认 wire namespace。 +- `bindTypeRTGateway(this, serviceKey, options?)` 为无法继承 `GatewayService` 的 Service 提供同样可见且冻结的绑定。 - `remoteMethods(service)` 返回按声明顺序排列、与内部状态分离的快照,供 Gateway 的 SRC 回退路径使用。 Host 方法通过将 `signal: AbortSignal` 声明为最后一个参数来启用协作式取消。`InvocationDescriptor.cancellation` 记录这个保留的注入点;signal 绝不会成为 JSON 参数或 lookup 字段。SRC 识别末位参数名,严格生成还会校验它是否具有全局 `AbortSignal` 类型。 -装饰器初始化器将标记保存在以服务 prototype 为键的模块私有 `WeakMap` 中。它们不会在构造函数上添加 symbol,也不会添加 prototype 属性、参数元数据或运行时反射字段。服务通过自身的 `typertGateway` 绑定字段显式接入。 +装饰器初始化器将标记保存在以服务 prototype 为键的模块私有 `WeakMap` 中。它们不会在构造函数上添加 symbol,也不会添加 prototype 属性、参数元数据或运行时反射字段。`GatewayService` 会暴露与显式 helper 相同的 public readonly `typertGateway` 绑定。 ## TypeRT 协议 diff --git a/packages/typert/type-meta/src/index.ts b/packages/typert/type-meta/src/index.ts index 92438ee0fa..4d4457b5be 100644 --- a/packages/typert/type-meta/src/index.ts +++ b/packages/typert/type-meta/src/index.ts @@ -4,6 +4,7 @@ * @module @deepseek-ai/dsh-type-meta */ +import { Service, type Context } from 'cordis' import type { TypeRTContextMap } from './types.ts' export type { @@ -104,6 +105,23 @@ export function bindTypeRTGateway( return Object.freeze({ service, serviceKey, namespace }) } +/** Cordis Service base that exposes its registered name through TypeRT Gateway. */ +export abstract class GatewayService extends Service { + /** Visible binding consumed by the Gateway's source-mode discovery. */ + readonly typertGateway: TypeRTGatewayBinding + + /** + * Register the Service and bind the same key to TypeRT Gateway. + * @param ctx - owning Cordis Context. + * @param serviceKey - exact Cordis service key and default wire namespace. + * @param options - optional distinct wire namespace. + */ + protected constructor(ctx: Context, serviceKey: string, options: TypeRTGatewayBindingOptions = {}) { + super(ctx, serviceKey) + this.typertGateway = bindTypeRTGateway(this, this.name, options) + } +} + /** * Mark one public instance method as a direct Remote invocation. * @param _method - decorated method; retained only by the class itself. diff --git a/packages/typert/type-meta/tests/fixtures/source-launch.ts b/packages/typert/type-meta/tests/fixtures/source-launch.ts index 68f886dff1..b13a80796d 100644 --- a/packages/typert/type-meta/tests/fixtures/source-launch.ts +++ b/packages/typert/type-meta/tests/fixtures/source-launch.ts @@ -1,12 +1,15 @@ +import { Context } from 'cordis' import { - bindTypeRTGateway, + GatewayService, Remote, RemoteContext, remoteMethods, } from '@deepseek-ai/dsh-type-meta' -class Goals { - readonly typertGateway = bindTypeRTGateway(this, 'goals') +class Goals extends GatewayService { + constructor(ctx: Context) { + super(ctx, 'goals') + } @Remote create(value: string): string { @@ -19,7 +22,7 @@ class Goals { } } -const methods = remoteMethods(new Goals()) +const methods = remoteMethods(new Goals(new Context())) const actual = JSON.stringify(methods) const expected = JSON.stringify([ { method: 'create', invocation: { kind: 'direct' } }, diff --git a/packages/typert/type-meta/tests/type-meta.spec.ts b/packages/typert/type-meta/tests/type-meta.spec.ts index f25c367914..8a2a4372ce 100644 --- a/packages/typert/type-meta/tests/type-meta.spec.ts +++ b/packages/typert/type-meta/tests/type-meta.spec.ts @@ -1,8 +1,10 @@ import { execFileSync } from 'node:child_process' import { fileURLToPath } from 'node:url' +import { Context } from 'cordis' import { describe, expect, it } from 'vitest' import { bindTypeRTGateway, + GatewayService, Remote, RemoteContext, remoteMethods, @@ -16,9 +18,11 @@ declare module '@deepseek-ai/dsh-type-meta' { } describe('type-meta Remote declarations', () => { - it('executes standard decorator syntax through the Vitest source transform', () => { - class Goals { - readonly typertGateway = bindTypeRTGateway(this, 'goals') + it('binds a GatewayService name and executes decorators through the Vitest source transform', async () => { + class Goals extends GatewayService { + constructor(ctx: Context) { + super(ctx, 'goals') + } @Remote create(value: string): string { @@ -31,11 +35,26 @@ describe('type-meta Remote declarations', () => { } } - const goals = new Goals() + class NamespacedGoals extends GatewayService { + constructor(ctx: Context) { + super(ctx, 'internalGoals', { namespace: 'goals' }) + } + } + + const ctx = new Context() + const goals = new Goals(ctx) + const namespaced = new NamespacedGoals(ctx) + expect(goals.typertGateway).toEqual({ service: goals, serviceKey: 'goals', namespace: 'goals' }) + expect(namespaced.typertGateway).toEqual({ + service: namespaced, + serviceKey: 'internalGoals', + namespace: 'goals', + }) expect(remoteMethods(goals)).toEqual([ { method: 'create', invocation: { kind: 'direct' } }, { method: 'scoped', invocation: { kind: 'context', context: 'metaFixture' } }, ]) + await ctx.fiber.dispose() }) it('executes standard decorator syntax through the TSX source launcher', () => { From ede278d0c79ff07a53df018d782ed4753356e308 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 01:16:07 +0800 Subject: [PATCH 071/104] fix(connection): mint RPC ids on insecure origins --- packages/client/connection/src/client/fixture.ts | 3 ++- .../client/connection/src/client/random-uuid.ts | 14 ++++++++++++++ packages/client/connection/src/client/rpc.ts | 3 ++- .../client/connection/tests/client-apply.spec.ts | 9 ++++++++- 4 files changed, 26 insertions(+), 3 deletions(-) create mode 100644 packages/client/connection/src/client/random-uuid.ts diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 31747bb311..e13c0a19f6 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -35,10 +35,11 @@ import type { } from './api.ts' import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api' import { AbstractApiClient, RpcId, SESSION_SEARCH_RESULT_LIMIT } from './api.ts' +import { randomUuid } from './random-uuid.ts' /** The fake carrier mints like a real one (business code never mints). */ function rpcRequest

    (payload: P): RpcRequest

    { - return { rpcId: RpcId(crypto.randomUUID()), payload } + return { rpcId: RpcId(randomUuid()), payload } } function text(t: string): ContentBlock[] { diff --git a/packages/client/connection/src/client/random-uuid.ts b/packages/client/connection/src/client/random-uuid.ts new file mode 100644 index 0000000000..dc3106bd86 --- /dev/null +++ b/packages/client/connection/src/client/random-uuid.ts @@ -0,0 +1,14 @@ +/** Browser-safe UUID generation for client-side wire correlation. */ + +/** + * Generate an RFC 4122 version 4 UUID without requiring a secure context. + * @returns a UUID backed by `crypto.getRandomValues()`, which browsers expose on insecure origins. + */ +export function randomUuid(): string { + const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16)) + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + view.setUint8(6, (view.getUint8(6) & 0x0f) | 0x40) + view.setUint8(8, (view.getUint8(8) & 0x3f) | 0x80) + const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('') + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` +} diff --git a/packages/client/connection/src/client/rpc.ts b/packages/client/connection/src/client/rpc.ts index 0c12149d7b..7883f2a9d3 100644 --- a/packages/client/connection/src/client/rpc.ts +++ b/packages/client/connection/src/client/rpc.ts @@ -6,6 +6,7 @@ import { type ClientRequest, } from '@deepseek-ai/dsh-host-apiproxy/api' import type { ClientConnectionRpc } from '../rpc.ts' +import { randomUuid } from './random-uuid.ts' const INTERNAL_BASE = 'http://dsh.internal' const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/ @@ -19,7 +20,7 @@ export function createWebConnectionRpc(): ClientConnectionRpc { return { async call(channel, endpoint, payload, signal) { assertTarget(channel, endpoint) - const rpcId = RpcId(crypto.randomUUID()) + const rpcId = RpcId(randomUuid()) const message: ClientRequest = { type: 'client-request', rpcId, diff --git a/packages/client/connection/tests/client-apply.spec.ts b/packages/client/connection/tests/client-apply.spec.ts index 6bf9c26b46..41e8e9b0e2 100644 --- a/packages/client/connection/tests/client-apply.spec.ts +++ b/packages/client/connection/tests/client-apply.spec.ts @@ -204,8 +204,13 @@ describe('connection client apply', () => { expect(sockets[0]?.readyState).toBe(FakeWebSocket.CLOSED) }) - it('carries RPC calls over the shared API channel with rpcId echo validation', async () => { + it('carries RPC calls without requiring secure-context randomUUID', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '' } + vi.stubGlobal('crypto', { + getRandomValues(bytes: Uint8Array) { + return bytes.fill(0) + }, + }) const handle = await mount() const original = globalThis.fetch const seen: { url: string; body: unknown }[] = [] @@ -225,11 +230,13 @@ describe('connection client apply', () => { .resolves.toEqual({ ok: true, value: { ref: 'goal-1' } }) } finally { globalThis.fetch = original + vi.unstubAllGlobals() } expect(seen).toHaveLength(1) expect(seen[0]?.url).toBe('http://dsh.internal/api/goals/create') expect(seen[0]?.body).toMatchObject({ type: 'client-request', + rpcId: '00000000-0000-4000-8000-000000000000', method: 'goals/create', payload: { args: { agentId: 'agent-1' } }, }) From 2f619b1b88ebd0ffc054ff24852c8775d98946a1 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 01:59:34 +0800 Subject: [PATCH 072/104] docs: document TypeRT API-Gateway --- docs/api-gateway.i18n.yaml | 6 ++ docs/api-gateway.md | 157 ++++++++++++++++++++++++++++++++++++ docs/api-gateway.zh.md | 157 ++++++++++++++++++++++++++++++++++++ docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 1 + docs/architecture.zh.md | 1 + docs/development.i18n.yaml | 4 +- docs/development.md | 2 + docs/development.zh.md | 2 + 9 files changed, 330 insertions(+), 4 deletions(-) create mode 100644 docs/api-gateway.i18n.yaml create mode 100644 docs/api-gateway.md create mode 100644 docs/api-gateway.zh.md diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml new file mode 100644 index 0000000000..87abb10c88 --- /dev/null +++ b/docs/api-gateway.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 docs/api-gateway.md +api-gateway.md: 76af93880d278a17dc46370fd5065fdcdadb9fb6 +api-gateway.zh.md: d447cea6b64bf88084f86a210a5f654bd9445d6c diff --git a/docs/api-gateway.md b/docs/api-gateway.md new file mode 100644 index 0000000000..76af93880d --- /dev/null +++ b/docs/api-gateway.md @@ -0,0 +1,157 @@ +# API Gateway + +English | [中文](api-gateway.zh.md) + +This is the current-state reference for the TypeRT API Gateway. It describes how business services declare unary Remote methods, how the build generates Host and Client contracts, and how calls reuse the Connection RPC and `/api` route. Session events, incremental data, and other streaming protocols are outside this document's scope; they may use the same Connection but do not use Remote method descriptors. + +## Programming model + +Business services use `@Remote` or `@RemoteContext` to select the methods exposed to the Client. Unmarked methods do not enter the generated Client types or runtime contributions and cannot be called through `ctx.api`. + +`@Remote` denotes calling a Cordis service registered on the root Host Context. Complex Host objects cannot cross the wire directly; the business package must declare their association with a wire identity through `TypeRTLookupMap` and register a resolution provider with `ctx.typert.lookups` at runtime. For example, an `Agent` parameter named `agent` in the Host signature produces an `agentId` wire field, and the Gateway resolves that id to the current live object before invoking the business method. + +`@RemoteContext(key)` first resolves an identity to a scoped Context through `ctx.typert.contexts`, then obtains the service from that Context and invokes the method. It applies when the method itself depends on scoped composition and does not need to receive objects such as `Agent` explicitly. + +Services normally extend `GatewayService` so the constructor explicitly binds the Cordis service key and default Remote namespace. A service that already has another base class can instead declare `readonly typertGateway = bindTypeRTGateway(this, serviceKey)`; both forms leave an inspectable public binding and do not depend on the compiler injecting a symbol into the constructor. + +```ts +import type { Agent } from '@deepseek-ai/dsh-agent' +import { GatewayService, Remote, RemoteContext } from '@deepseek-ai/dsh-type-meta' +import type { Context } from 'cordis' + +export interface CreateGoalRequest { + objective: string +} + +export interface CreateGoalResult { + accepted: boolean +} + +export class GoalService extends GatewayService { + constructor(ctx: Context) { + super(ctx, 'goals') + } + + @Remote('create') + createForClient( + agent: Agent, + request: CreateGoalRequest, + signal: AbortSignal, + ): CreateGoalResult { + signal.throwIfAborted() + return this.create(agent, request) + } + + @RemoteContext('agent', 'current') + currentForClient(): CreateGoalResult { + return { accepted: true } + } + + private create(_agent: Agent, request: CreateGoalRequest): CreateGoalResult { + return { accepted: request.objective.length > 0 } + } +} +``` + +Remote methods may return a value synchronously or return a Promise. For cooperative cancellation, the final parameter in the Host signature must be `signal: AbortSignal` using the global type; it is recorded in the descriptor instead of entering `args`, while the generated Client method accepts an optional final `AbortSignal`. + +The Client uses concrete functions on ordinary objects, not a JavaScript Proxy. Direct Remotes appear under `ctx.api.`; when an `@Remote` method has exactly one lookup parameter and a same-named `TypeRTContextMap` uses the same wire identity, the generator also projects the method without that identity parameter onto the corresponding scoped Context. `@RemoteContext` generates only the scoped invocation interface. + +```ts +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { AgentContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context } from 'cordis' +import type {} from '@deepseek-ai/dsh-client-remotes/client' + +declare const ctx: Context +declare const agentCtx: AgentContext +declare const agentId: SessionId + +await ctx.api.goals.create(agentId, { objective: 'ship it' }) +await agentCtx.goals.create({ objective: 'ship it' }) +``` + +Client applications assemble only `@deepseek-ai/dsh-client-remotes`. That package imports the `/remote` subpaths of selected business packages as runtime values, mounts their contributions on `ctx.api`, and re-exports the declaration merges from the same files. Adding a Host Remote package is an explicit choice by the Client composition owner; business components do not need to load the Host API Gateway or the business package's Remote JS separately. + +A future TUI can assemble the same React-independent `client-remotes` and `ctx.api` contract, so the Host methods visible to it are likewise limited to the Remote methods selected at generation time. This document does not define or implement the TUI composition. + +## Component responsibilities + +| Location | Package or entry | Responsibility | +|---|---|---| +| Shared | `@deepseek-ai/dsh-type-meta` | Declares decorators, Gateway bindings, merge-extensible protocol maps, invocation descriptors, and provider types; starts no TypeScript analysis and registers no Cordis services | +| Build | `@deepseek-ai/dsh-typert-generator` | Strictly analyzes Remote signatures, the type graph, lookups, Contexts, and source locations from the Host `ts.Program`, then generates Host and Host-for-Client artifacts | +| Host | `@deepseek-ai/dsh-typert-registry` and Loader | Places generated Host descriptors, schemas, and business-package registrations in `ctx.typert`, and holds lookup and Context providers | +| Host | `@deepseek-ai/dsh-host-api-gateway` | Provides `ctx.typertGateway`, claims Remote endpoints, resolves objects or Contexts, invokes live Cordis services, and validates boundaries | +| Client | `@deepseek-ai/dsh-host-api-gateway/client` | Provides `ctx.api`, mounts generated descriptors as concrete methods, and initiates, validates, and cancels calls through the Connection | +| Client | `@deepseek-ai/dsh-client-remotes/client` | Explicitly selects and mounts the `/remote` contributions allowed by the application and brings the corresponding declaration merges into business code | +| Both | `@deepseek-ai/dsh-client-connection` | Provides the RPC carrier, request correlation, trust boundary, cancellation, response envelope, and current `/api` HTTP bridge | + +The Host API Gateway package owns the Host dispatcher and Client API as peer entries, but the two builds never enter the same `ts.Program`. The Host entry does not import the Client Cordis `Context` merge, and the Client entry does not import the Host Gateway service. + +## Strict generation pipeline + +The root build orders `build:lib:host`, `build:lib:client`, and `build:web`. The Host lib build first runs `build:lib:contracts`: it compiles the TypeRT generator, then starts a Host `ts.Program` through `tsdown.typert-host.config.ts` with `tsconfig.host.json` as its seed. The generator does not put the Host and Client aggregates in the same program, so it does not trigger conflicts between the two Cordis `Context` declaration merges. + +Each contributing business package writes generated files to its own `lib/` directory, not to its source directory: + +| File | Consumer | Contents | +|---|---|---| +| `typert.host.js` | Host Loader | Runtime reflection for the Host face, strict invocation descriptors, and schema registration values | +| `typert.host.d.ts` | Host type system | Generated declarations for the Host face | +| `typert.remote-client.js` | `client-remotes` | A mountable `TypeRTRemoteContribution` containing strict descriptors and runtime codecs | +| `typert.remote-client.d.ts` | Client type system | Declaration merges for `TypeRTRemoteNamespaceMap` and `TypeRTRemoteContextMap`, plus Client-safe type references | +| `typert.remote-client.d.ts.map` | Editor | Maps generated method properties back to Remote method declarations in the Host package | + +Business packages expose the Host Loader entry through `./typert` and the Host-for-Client entry through `./remote`. The generator also validates these package exports and published-file lists; it generates artifacts only for explicit contribution packages that provide the corresponding entry. + +Parameter names in Remote Client declarations come from wire fields, while parameter and return types reference Client-safe types exported by the original business package. The declaration map resolves the generated property behind `ctx.api.goals.create` back to the Host source method marked with `@Remote`, so editors that support declaration maps can navigate from a Client call to the real implementation instead of stopping at the generated `.d.ts`. + +Strict analysis requires a Remote to be a public, non-static instance method with a concrete implementation. The method cannot be generic; parameters must be required, named simple identifiers and cannot use destructuring, default values, rest parameters, or optional parameters. TypeRT generates strict schemas for ordinary JSON-representable types; complex objects such as workspace classes must have a unique `TypeRTLookupMap` declaration. Lookup and Context packages are responsible for both static declaration merges and runtime provider registration; if either side is missing, the build or earliest resolvable runtime boundary fails. + +## Runtime invocation + +Remote and API Proxy currently share the Connection's `/api` route; there is no separate `/api2` server or second Connection. The Client API calls `connection.rpc.call('/api', '/', { args }, signal)`; the current 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 in the future 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 at the boundary before entering or after leaving business code. + +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. + +## SRC development fallback + +When the Host starts from source through `node --import tsx/esm`, it does not execute the TypeRT compiler plugin. Standard decorator initializers still record the method name and invocation mode in a module-private `WeakMap`, while `GatewayService` or `bindTypeRTGateway()` supplies the explicit service binding; the Gateway can therefore construct a weaker temporary descriptor without starting a `ts.Program`. + +The SRC fallback parses simple parameter names from the live function. When a parameter name matches the `parameter` of a registered lookup, such as `agent` or `session`, it uses the lookup's `agentId` or `sessionId` wire field and resolves the object on the Host; other parameters are checked only for cycle-free, JSON-safe data with no special prototype. `@RemoteContext` directly uses the wire field of a registered Host Context provider. SRC does not read TypeScript types, generate Zod schemas, infer optional parameters, or support destructuring, default values, rest parameters, or duplicate parameter names. + +SRC solves only dispatch for a Host process running from source. The Client does not discover decorators from the running Host, and the Client API refuses to mount SRC descriptors that lack strict codecs; its types, codecs, and Remote registration values always come from the most recently generated `lib/typert.remote-client.*` artifacts. + +## Development mode + +A complete build generates Host contracts before compiling the Host, Client, and Web, so it is the deterministic entry for creating or refreshing all artifacts: + +```sh +pnpm run build +``` + +Web development normally starts the source Host after one complete build and runs the Client plugin watcher in another terminal: + +```sh +pnpm run dsh -- web --dev +pnpm run dev:web +``` + +`dsh` starts the Host source through tsx, so the Host can use the SRC fallback; `dev:web` watches only Client plugins with a `dshClient` declaration and rewrites their `lib/client.js`. It does not analyze Host decorators or generate Remote Client DTS. + +Changing only a Remote method's implementation body without changing its contract does not require regenerating the TypeRT files. After adding or removing a decorator or changing an export name, namespace, parameter, return value, lookup, Context, or cancellation signature, regenerate the strict contracts before the Client bundle consumes the new artifacts: + +```sh +pnpm run build:lib:contracts +``` + +The running Client watcher consumes these generated files when it rebundles; without a watcher, run `pnpm run build:lib:client`. Recompiling only the frontend source cannot infer new types from Host decorators. `pnpm run typecheck` includes `build:lib:contracts` as a prerequisite, and CI and release builds also use the strict generation pipeline. + +## Boundaries + +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. diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md new file mode 100644 index 0000000000..d447cea6b6 --- /dev/null +++ b/docs/api-gateway.zh.md @@ -0,0 +1,157 @@ +# API Gateway + +[English](api-gateway.md) | 中文 + +本文是 TypeRT API Gateway 的当前状态参考。它描述业务 Service 如何声明一元 Remote 方法、构建如何生成 Host 与 Client 契约,以及调用如何复用 Connection 的 RPC 与 `/api` 路由。会话事件、增量数据和其他流协议不属于本文范围;它们可以使用同一个 Connection,但不使用 Remote 方法描述符。 + +## 编程模型 + +业务 Service 通过 `@Remote` 或 `@RemoteContext` 选择对 Client 开放的方法。未标记的方法不会进入生成的 Client 类型或运行时贡献,也不能通过 `ctx.api` 调用。 + +`@Remote` 表示调用根 Host Context 中注册的 Cordis Service。复杂的 Host 对象不能直接跨 wire 传输;业务包必须通过 `TypeRTLookupMap` 声明它与 wire identity 的关联,并在运行时向 `ctx.typert.lookups` 注册解析提供方。例如 `Agent` 参数在 Host 签名中名为 `agent`,生成的 wire 字段为 `agentId`,Gateway 在调用业务方法前将 id 解析为当前的实时对象。 + +`@RemoteContext(key)` 表示先通过 `ctx.typert.contexts` 把 identity 解析为一个作用域 Context,再从该 Context 取得 Service 并调用方法。它适用于方法本身依赖作用域组合、而不需要显式接收 `Agent` 等对象的情形。 + +Service 通常继承 `GatewayService`,让 Cordis service key 与默认 Remote namespace 在构造器中显式绑定。已有其他基类的 Service 可以改为声明 `readonly typertGateway = bindTypeRTGateway(this, serviceKey)`;两种方式都会留下可检查的公开 binding,不依赖编译器向构造函数注入 symbol。 + +```ts +import type { Agent } from '@deepseek-ai/dsh-agent' +import { GatewayService, Remote, RemoteContext } from '@deepseek-ai/dsh-type-meta' +import type { Context } from 'cordis' + +export interface CreateGoalRequest { + objective: string +} + +export interface CreateGoalResult { + accepted: boolean +} + +export class GoalService extends GatewayService { + constructor(ctx: Context) { + super(ctx, 'goals') + } + + @Remote('create') + createForClient( + agent: Agent, + request: CreateGoalRequest, + signal: AbortSignal, + ): CreateGoalResult { + signal.throwIfAborted() + return this.create(agent, request) + } + + @RemoteContext('agent', 'current') + currentForClient(): CreateGoalResult { + return { accepted: true } + } + + private create(_agent: Agent, request: CreateGoalRequest): CreateGoalResult { + return { accepted: request.objective.length > 0 } + } +} +``` + +Remote 方法可以同步返回或返回 Promise。若需要协作式取消,Host 签名的最后一个参数必须是全局类型的 `signal: AbortSignal`;它记录在描述符中而不是进入 `args`,Client 生成的方法则接受最后一个可选的 `AbortSignal`。 + +Client 使用普通对象上的具体函数,不使用 JavaScript Proxy。直接 Remote 出现在 `ctx.api.`;当一个 `@Remote` 方法恰好有一个 lookup 参数、且同名 `TypeRTContextMap` 使用相同 wire identity 时,生成器还会把去掉该 identity 参数后的方法投影到对应作用域 Context。`@RemoteContext` 只生成作用域调用界面。 + +```ts +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { AgentContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context } from 'cordis' +import type {} from '@deepseek-ai/dsh-client-remotes/client' + +declare const ctx: Context +declare const agentCtx: AgentContext +declare const agentId: SessionId + +await ctx.api.goals.create(agentId, { objective: 'ship it' }) +await agentCtx.goals.create({ objective: 'ship it' }) +``` + +Client 应用只装配 `@deepseek-ai/dsh-client-remotes`。该包以运行时值导入被选业务包的 `/remote` 子路径,并向 `ctx.api` 挂载贡献,同时重新导出相同文件中的声明合并。增加一个 Host Remote 包是 Client 组合所有者的显式选择;业务组件不需要分别加载 Host API Gateway 或业务包的 Remote JS。 + +未来的 TUI 可以装配同一个不依赖 React 的 `client-remotes` 与 `ctx.api` 契约,因此它能看到的 Host 方法同样只限于生成时选择的 Remote 方法。本文不定义或实现 TUI 组合。 + +## 组件职责 + +| 位置 | 包或入口 | 职责 | +|---|---|---| +| 共享 | `@deepseek-ai/dsh-type-meta` | 声明 decorator、Gateway binding、可合并协议映射、调用描述符及提供方类型;不启动 TypeScript 分析,也不注册 Cordis 服务 | +| 构建 | `@deepseek-ai/dsh-typert-generator` | 从 Host `ts.Program` 严格分析 Remote 签名、类型图、lookup、Context 与源码位置,并生成 Host 和 Host-for-Client 产物 | +| Host | `@deepseek-ai/dsh-typert-registry` 与 Loader | 把生成的 Host 描述符、schema 及业务包注册项放入 `ctx.typert`,并持有 lookup 与 Context 提供方 | +| Host | `@deepseek-ai/dsh-host-api-gateway` | 提供 `ctx.typertGateway`,认领 Remote endpoint,解析对象或 Context,调用实时 Cordis Service 并校验边界 | +| Client | `@deepseek-ai/dsh-host-api-gateway/client` | 提供 `ctx.api`,把生成的描述符挂成具体方法,并通过 Connection 发起、校验和取消调用 | +| Client | `@deepseek-ai/dsh-client-remotes/client` | 显式选择并挂载本应用允许使用的 `/remote` 贡献,向业务代码带入对应的声明合并 | +| 双侧 | `@deepseek-ai/dsh-client-connection` | 提供 RPC carrier、请求关联、信任边界、取消、响应 envelope 与当前 `/api` HTTP bridge | + +Host API Gateway 包同时拥有 Host dispatcher 与 Client API 两个对等入口,但两侧构建不会进入同一个 `ts.Program`。Host 入口不导入 Client 的 Cordis `Context` 合并,Client 入口也不导入 Host Gateway 服务。 + +## 严格生成链路 + +根构建按 `build:lib:host`、`build:lib:client`、`build:web` 排序。Host lib 构建首先运行 `build:lib:contracts`:它先编译 TypeRT generator,再通过 `tsdown.typert-host.config.ts` 以 `tsconfig.host.json` 为种子启动 Host `ts.Program`。生成器不会把 Host 与 Client 聚合放入同一个 program,因而不会触发两侧 Cordis `Context` 声明合并冲突。 + +每个贡献业务包把生成文件写入自己的 `lib/`,而不是源码目录: + +| 文件 | 消费方 | 内容 | +|---|---|---| +| `typert.host.js` | Host Loader | Host face 的运行时反射、严格调用描述符和 schema 注册值 | +| `typert.host.d.ts` | Host 类型系统 | Host face 的生成声明 | +| `typert.remote-client.js` | `client-remotes` | 可挂载的 `TypeRTRemoteContribution`,包含严格描述符与运行时 codec | +| `typert.remote-client.d.ts` | Client 类型系统 | `TypeRTRemoteNamespaceMap` 与 `TypeRTRemoteContextMap` 的声明合并及 Client-safe 类型引用 | +| `typert.remote-client.d.ts.map` | 编辑器 | 将生成的方法属性映射回 Host 包中的 Remote 方法声明 | + +业务包通过 `./typert` 暴露 Host Loader 入口,通过 `./remote` 暴露 Host-for-Client 入口。生成器同时校验这些 package export 及发布文件清单;只有具备相应入口的显式贡献包才会生成产物。 + +Remote Client 声明中的参数名来自 wire 字段,参数和返回类型则引用原业务包导出的 Client-safe 类型。声明 map 把 `ctx.api.goals.create` 最终解析到的生成属性映射到带 `@Remote` 的 Host 源方法,因此支持 declaration-map 的编辑器可以从 Client 调用跳到真实实现,而不是停在生成的 `.d.ts`。 + +严格分析要求 Remote 是公开、非静态、有具体实现的实例方法。方法不能是泛型;参数必须是具名且必填的简单标识符,不能使用解构、默认值、rest 或可选参数。可 JSON 表示的普通类型由 TypeRT 生成严格 schema;工作区 class 等复杂对象必须具有唯一的 `TypeRTLookupMap` 声明。lookup 与 Context 包同时负责静态声明合并和运行时提供方注册,缺少任一侧都会在构建或最早可解析的运行时边界报错。 + +## 运行时调用 + +当前 Remote 与 API Proxy 共用 Connection 的 `/api` 路由,不存在独立 `/api2` server 或第二套 Connection。Client API 调用 `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 和 request cancellation,Gateway 只拥有 Remote 数据协议和业务分发。未来替换 Connection carrier 不要求改变 Remote 描述符或 Client 编程界面。 + +Gateway 每次调用都从当前注册表解析描述符和实时 Service,不缓存业务对象。它要求 `args` 的字段集合与描述符完全一致,先用 codec 校验 wire 值,再通过注册的 lookup 或 Context provider 解析对象或接收者,最后调用 binding 指向的 Service 方法并校验返回值。缺少 provider、identity 未命中、binding 不一致、参数多缺、schema 失败和方法不存在都在进入或离开业务边界时失败。 + +Client 卸载一个贡献时会一起移除描述符和具体方法,中止其进行中的调用,并使外部仍持有的旧方法句柄拒绝继续调用。Host 上已经注册过的严格 endpoint 被撤回后也不会降级到 SRC 推断,以免热卸载悄然降低校验强度。 + +## SRC 开发回退 + +Host 通过 `node --import tsx/esm` 从源码启动时不会执行 TypeRT 编译插件。标准 decorator 初始化器仍会把方法名和调用模式记录到模块私有 `WeakMap`,`GatewayService` 或 `bindTypeRTGateway()` 则提供显式 service binding;Gateway 因而可以在不启动 `ts.Program` 的情况下构造一个较弱的临时描述符。 + +SRC 回退从运行中函数解析简单参数名。参数名与某个已注册 lookup 的 `parameter` 相同,例如 `agent` 或 `session`,就使用其 `agentId` 或 `sessionId` wire 字段并在 Host 解析对象;其他参数只检查值是否为无循环、无特殊 prototype 的 JSON-safe 数据。`@RemoteContext` 直接使用已注册 Host Context provider 的 wire 字段。SRC 不读取 TypeScript 类型,不生成 Zod schema,不推断可选参数,也不支持解构、默认值、rest 或重复参数名。 + +SRC 只解决 Host 源码进程的分发问题。Client 不会从运行中的 Host 发现 decorator,Client API 也拒绝挂载缺少严格 codec 的 SRC 描述符;其类型、codec 和 Remote 注册值始终来自最近一次生成的 `lib/typert.remote-client.*`。 + +## 开发模式 + +完整构建会先生成 Host 契约,再编译 Host、Client 与 Web,因此是建立或刷新所有产物的确定性入口: + +```sh +pnpm run build +``` + +Web 开发通常在完成一次构建后启动源码 Host,并在另一个终端运行 Client plugin watcher: + +```sh +pnpm run dsh -- web --dev +pnpm run dev:web +``` + +`dsh` 通过 tsx 启动 Host 源码,所以 Host 可以使用 SRC 回退;`dev:web` 只监听带 `dshClient` 声明的 Client plugin 并重写其 `lib/client.js`,它不会分析 Host decorator,也不会生成 Remote Client DTS。 + +只修改 Remote 方法实现体而不改变契约时,无需重新生成 TypeRT 文件。新增或删除 decorator、修改导出名、namespace、参数、返回值、lookup、Context 或取消签名时,先重新生成严格契约,再让 Client bundle 使用新的产物: + +```sh +pnpm run build:lib:contracts +``` + +运行中的 Client watcher 会在重新打包时消费这些生成文件;没有 watcher 时运行 `pnpm run build:lib:client`。仅重新编译前端源码不能从 Host decorator 推导新类型。`pnpm run typecheck` 自带 `build:lib:contracts` 前置步骤,CI 与发布构建也使用严格生成链路。 + +## 边界 + +Remote 只处理有单个请求与单个结果的一元方法调用。Session event stream、分页、增量 reduce、projection 和实体子流需要独立的数据协议与注册模型;即使它们复用 Connection,也不应伪装成 Remote 方法或放入调用描述符。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 8daacae254..774bc296b1 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/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 docs/architecture.md -architecture.md: 81464d9c8800556565c84d33239882dc750180a8 -architecture.zh.md: c02bca4f12c3758723b0fc818c89080dccf435d9 +architecture.md: db5991d98dfbc6b04992d62d5a465c375c9a78b8 +architecture.zh.md: 2eb8c3834a6ffc3283c8aa669be481b534bb5914 diff --git a/docs/architecture.md b/docs/architecture.md index 81464d9c88..db5991d98d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -48,6 +48,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts; packages contribute services, | `ctx.credentials` | [`credentials/`](../packages/credentials/README.md) | named secret references resolved per operation, never inlined in configuration | | `ctx.directoryPicker` | [`host/directory-picker`](../packages/host/directory-picker/README.md) | GUI-host directory picking (`native`/`browse` interactions) | | `ctx.typert` | [`typert/registry`](../packages/typert/registry/README.md) | runtime registry for generated package reflection and live Zod schemas | +| `ctx.typertGateway` | [`host/api-gateway`](../packages/host/api-gateway/README.md) | dispatches TypeRT Remote unary calls through the [API Gateway](api-gateway.md) | | `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | package-name-selected registry of package-owned runtime checks | ## Event diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index c02bca4f12..2eb8c3834a 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -48,6 +48,7 @@ | `ctx.credentials` | [`credentials/`](../packages/credentials/README.md) | 具名密钥引用,按操作解析,绝不内联进配置 | | `ctx.directoryPicker` | [`host/directory-picker`](../packages/host/directory-picker/README.md) | GUI 宿主目录选取(`native`/`browse` 交互) | | `ctx.typert` | [`typert/registry`](../packages/typert/registry/README.md) | 生成的包反射和实时 Zod schema 的运行时注册表 | +| `ctx.typertGateway` | [`host/api-gateway`](../packages/host/api-gateway/README.md) | 通过 [API Gateway](api-gateway.md) 分发 TypeRT Remote 一元调用 | | `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | 按包名筛选包自有运行时检查的注册表 | ## 事件 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 95ed34cce0..2ea336b1f0 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: 30a2bd0a2c97df8d3d75ec50f47b861b3a65590e -development.zh.md: 5582a85429c97c3e31517a495c69392b80885f7d +development.md: d480f548dd24ea81d132e4b4c0cc364ce1b0cd53 +development.zh.md: 08ef7fd2d3da7db83eb3ca4dff9f9c85f6d7cb5e diff --git a/docs/development.md b/docs/development.md index 30a2bd0a2c..d480f548dd 100644 --- a/docs/development.md +++ b/docs/development.md @@ -62,6 +62,8 @@ Host and client stay two aggregate programs because both sides declaration-merge Static analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md). +Business services declare callable methods on the Host with `@Remote` or `@RemoteContext`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `client-remotes` composition loads those contributions and mounts concrete methods on `ctx.api` or the corresponding scoped Context. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order. + If a relevant local check consumes built package output, build once first: ```sh diff --git a/docs/development.zh.md b/docs/development.zh.md index 5582a85429..08ef7fd2d3 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -62,6 +62,8 @@ host 与 client 保持两个聚合 program,是因为两侧在相同键下以 静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。 +业务 Service 在 Host 使用 `@Remote` 或 `@RemoteContext` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `client-remotes` 组合加载这些贡献并将具体方法挂载到 `ctx.api` 或对应的作用域 Context。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。 + 如果相关的本地检查需要使用构建后的包产物,请先构建一次: ```sh From da1ebd2b68a14af5d8688942fcedcfef59ff32a5 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:47:17 +0800 Subject: [PATCH 073/104] refactor(goal): call entity methods through remote API --- ...08-02-typert-remote-method-calls.i18n.yaml | 4 +- .../2026-08-02-typert-remote-method-calls.md | 13 ++-- ...026-08-02-typert-remote-method-calls.zh.md | 13 ++-- docs/cordis-catalog/services.md | 51 ++------------ .../client/remotes/tests/built-lib.e2e.ts | 12 +++- packages/client/ui-goal/README.i18n.yaml | 4 +- packages/client/ui-goal/README.md | 4 +- packages/client/ui-goal/README.zh.md | 4 +- packages/client/ui-goal/package.json | 5 +- packages/client/ui-goal/src/client/index.ts | 51 +++++++++----- .../ui-goal/tests/browser-plugin.spec.tsx | 70 ++++++++++++------- packages/client/ui-goal/tsconfig.json | 4 +- .../cordis/tool-cordis/src/api-catalog.ts | 30 ++------ packages/goal/goal/src/index.ts | 61 ++-------------- packages/goal/goal/tests/goal.spec.ts | 12 ++-- pnpm-lock.yaml | 6 +- 16 files changed, 140 insertions(+), 204 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index 3808a8d363..1a59abbb43 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: ade8eb827ae765677be8dcdb0ffec965c67bc4ab -2026-08-02-typert-remote-method-calls.zh.md: 2de887a2a0e46148fbb2b5ac52cfd7e3b2305b8d +2026-08-02-typert-remote-method-calls.md: c810a221a23549f3e17e25bd40fcc1fc0f9ec868 +2026-08-02-typert-remote-method-calls.zh.md: 38e3ca286dad98665269697f49fba731346e665e diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index ade8eb827a..c810a221a2 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -40,7 +40,7 @@ The Host Gateway does not depend on concrete implementations of `ctx.agents`, `c ## Business declarations -Ordinary direct calls use `@Remote`. When migrating to an existing Service or Registry, do not rename or alter existing methods. Add `remoteExport*` entry points at the end of the class and use decorator arguments to declare their short API names. A method explicitly declares every required business object in a top-level parameter position: +Ordinary direct calls use `@Remote`. When an existing method's parameters and result are already the intended Remote contract, decorate that method directly without renaming it. Add a `remoteExport*` adapter only when the wire contract needs a distinct request or result shape, and use the decorator argument to declare its short API name. A method explicitly declares every required business object in a top-level parameter position: ```text export class GoalService extends GatewayService { @@ -48,13 +48,14 @@ export class GoalService extends GatewayService { super(ctx, 'goals') } - create(agent: Agent, request: CreateGoalRequest): CreateGoalResult { + create(agent: Agent, request: CreateGoalRequest): GoalView { // Existing business method remains unchanged. } @Remote('create') remoteExportCreate(agent: Agent, request: CreateGoalRequest): CreateGoalResult { - return this.create(agent, request) + const view = this.create(agent, request) + return { ref: { id: view.id, revision: view.revision } } } } ``` @@ -84,7 +85,7 @@ A method that cooperatively supports cancellation declares `signal: AbortSignal` ## Decorators and the explicit Gateway facet -A decorator only states that a method participates in the Remote contract. It performs no runtime type reflection and injects no hidden symbol into a Service constructor. The arguments to `@Remote('create')` and `@RemoteContext('agent', 'create')` are external method names, while the actual member remains named `remoteExportCreate`. The member name becomes the external method name only when no alias is provided. Inheriting `GatewayService` is the normal explicit declaration that a Service has joined the Gateway; its public readonly `typertGateway` field keeps the binding visible on the runtime instance. +A decorator only states that a method participates in the Remote contract. It performs no runtime type reflection and injects no hidden symbol into a Service constructor. The arguments to `@Remote('create')` and `@RemoteContext('agent', 'create')` are external method names; the decorated member may be the business method itself or an adapter such as `remoteExportCreate`. The member name becomes the external method name only when no alias is provided. Inheriting `GatewayService` is the normal explicit declaration that a Service has joined the Gateway; its public readonly `typertGateway` field keeps the binding visible on the runtime instance. In SRC mode, the decorator may record the prototype, method name, and invocation mode in a `WeakMap` internal to `dsh-type-meta`. It writes no custom properties to a Service instance, prototype, constructor, or method function. @@ -174,7 +175,7 @@ import type { CreateGoalRequest, CreateGoalResult } from '@deepseek-ai/dsh-goal/ Consequently, `SessionId`, the Agent wire ID, the request, and the result all refer to the same TypeScript declaration in the Host and Browser Client. A future TUI can reuse them without a second set of types. Go to Definition, renames, and Find References for a DTO return to the one source location for the business type instead of stopping at a copy in a generated file. -Remote API methods themselves use declaration-map navigation. TypeRT anchors `InvocationModel.location` to the method-name token of the Host `remoteExport*` method and emits a source-map segment on the corresponding property of the namespace interface. After the TypeScript editor resolves `ctx.api.models.list` to its generated declaration, `typert.remote-client.d.ts.map` takes it to the Host Service's `remoteExportList` entry point. That entry point explicitly calls the existing, unrenamed `list()` method; the map does not misidentify the decorator, class, or full signature as the method definition. +Remote API methods themselves use declaration-map navigation. TypeRT anchors `InvocationModel.location` to the decorated Host method-name token and emits a source-map segment on the corresponding property of the namespace interface. For an adapter-backed endpoint, after the TypeScript editor resolves `ctx.api.models.list` to its generated declaration, `typert.remote-client.d.ts.map` takes it to the Host Service's `remoteExportList` entry point. That entry point explicitly calls the existing, unrenamed `list()` method; the map does not misidentify the decorator, class, or full signature as the method definition. TypeRT generates a wire Zod codec for the same symbol key. The Host Gateway uses it to validate input and encode results, while the Client API may use it to encode arguments and validate responses. If a complex type cannot produce a strict codec, the LIB build fails instead of degrading to `unknown` or unchecked JSON. @@ -480,7 +481,7 @@ Connection supplies the shared-channel interceptor and current HTTP carrier mapp ## Verification -- Goal Service keeps its existing business method and adds an explicit `typertGateway` plus `@Remote('create') remoteExportCreate(...)`, without a second route, codec, or Client method list. +- Goal Service directly decorates mutation methods whose business signatures already match the Remote contract and keeps `remoteExportCreate(...)` only to adapt `GoalView` into `CreateGoalResult`, without a second route, codec, or Client method list. - A clean `build:lib` emits Host and consumer Remote artifacts before Client compilation, including the business package's JS, DTS, and declaration map under `/remote`. - Importing `@deepseek-ai/dsh-goal/remote` adds the strict `api.goals.create(...)` type and declaration navigation to `remoteExportCreate`; omitting that import omits the namespace. - Mounting the same import's JS contribution supplies endpoint, parameter, result, lookup, Context, and Zod reflection and materializes the call without a handwritten stub. diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index 2de887a2a0..38e3ca286d 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -40,7 +40,7 @@ Host Gateway 不依赖 `ctx.agents`、`ctx.sessions`、`ctx.goals` 或 `ctx.http ## 业务声明 -普通直接调用使用 `@Remote`。迁移到现存 Service 或 Registry 时不重命名、不改变存量方法;类末尾新增 `remoteExport*` 出口,并由 decorator 参数声明短 API 名。方法需要哪个业务对象,就在顶层参数位置显式声明该对象: +普通直接调用使用 `@Remote`。现有方法的参数和结果已经是预期的 Remote 契约时,直接装饰该方法,不为此重命名。只有 wire 契约需要不同的请求或结果形态时,才新增 `remoteExport*` 适配器,并由 decorator 参数声明短 API 名。方法需要哪个业务对象,就在顶层参数位置显式声明该对象: ```text export class GoalService extends GatewayService { @@ -48,13 +48,14 @@ export class GoalService extends GatewayService { super(ctx, 'goals') } - create(agent: Agent, request: CreateGoalRequest): CreateGoalResult { + create(agent: Agent, request: CreateGoalRequest): GoalView { // Existing business method remains unchanged. } @Remote('create') remoteExportCreate(agent: Agent, request: CreateGoalRequest): CreateGoalResult { - return this.create(agent, request) + const view = this.create(agent, request) + return { ref: { id: view.id, revision: view.revision } } } } ``` @@ -84,7 +85,7 @@ export class ScopedGoalService extends GatewayService { ## Decorator 与显式 Gateway facet -Decorator 只表达“该方法参与 Remote 契约”,不负责运行时类型反射,也不向 Service constructor 注入隐藏 symbol。`@Remote('create')` 和 `@RemoteContext('agent', 'create')` 的参数是外部方法名,实际成员名保持 `remoteExportCreate`;未给别名时才使用成员名作为外部方法名。继承 `GatewayService` 是 Service 加入 Gateway 的常规显式声明;其 public readonly `typertGateway` 字段使运行时实例上的绑定保持可见。 +Decorator 只表达“该方法参与 Remote 契约”,不负责运行时类型反射,也不向 Service constructor 注入隐藏 symbol。`@Remote('create')` 和 `@RemoteContext('agent', 'create')` 的参数是外部方法名;被装饰成员既可以是业务方法本身,也可以是 `remoteExportCreate` 这样的适配器。未给别名时才使用成员名作为外部方法名。继承 `GatewayService` 是 Service 加入 Gateway 的常规显式声明;其 public readonly `typertGateway` 字段使运行时实例上的绑定保持可见。 SRC 运行时允许 decorator 在 `dsh-type-meta` 内部的 `WeakMap` 记录 prototype、方法名和调用模式。它不向 Service 实例、prototype、constructor 或方法函数写入自定义属性。 @@ -174,7 +175,7 @@ import type { CreateGoalRequest, CreateGoalResult } from '@deepseek-ai/dsh-goal/ 因此 `SessionId`、Agent wire ID、request 和 result 在 Host 与 Browser Client 中都指向同一 TypeScript declaration,未来 TUI 复用时也不需要第二份类型。DTO 的跳转定义、重命名和引用查找回到业务类型的唯一源码位置,而不是停在生成文件中的副本。 -Remote API 方法本身使用 declaration map 导航。TypeRT 把 `InvocationModel.location` 固定在 Host 的 `remoteExport*` 方法名 token,并在 namespace interface 的对应属性上写入 source-map segment;TypeScript editor 从 `ctx.api.models.list` 取得生成 declaration 后,再沿 `typert.remote-client.d.ts.map` 跳到 Host Service 的 `remoteExportList` 远程出口。该出口继续显式调用不改名的存量 `list()`,map 不把 decorator、class 或整个签名误当成方法定义位置。 +Remote API 方法本身使用 declaration map 导航。TypeRT 把 `InvocationModel.location` 固定在 Host 被装饰方法的方法名 token,并在 namespace interface 的对应属性上写入 source-map segment。对于由适配器支撑的 endpoint,TypeScript editor 从 `ctx.api.models.list` 取得生成 declaration 后,再沿 `typert.remote-client.d.ts.map` 跳到 Host Service 的 `remoteExportList` 远程出口。该出口继续显式调用不改名的存量 `list()`,map 不把 decorator、class 或整个签名误当成方法定义位置。 TypeRT 为同一 symbol key 生成 wire Zod codec。Host Gateway 用它校验输入和编码结果,Client API 可以用它编码参数并校验响应;复杂类型无法生成严格 codec 时,LIB 构建失败,不降级为 `unknown` 或无校验 JSON。 @@ -480,7 +481,7 @@ Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebS ## 验证 -- Goal Service 保留既有业务方法,并新增显式 `typertGateway` 与 `@Remote('create') remoteExportCreate(...)`,无需第二条路由、第二份 codec 或 Client 方法清单。 +- Goal Service 直接装饰业务签名已经符合 Remote 契约的变更类方法,仅保留 `remoteExportCreate(...)` 把 `GoalView` 适配为 `CreateGoalResult`,无需第二条路由、第二份 codec 或 Client 方法清单。 - 一次干净的 `build:lib` 会在 Client 编译前生成 Host 与消费方 Remote 产物,包括业务包 `/remote` 下的 JS、DTS 和 declaration map。 - 导入 `@deepseek-ai/dsh-goal/remote` 会加入严格的 `api.goals.create(...)` 类型,并可通过 declaration 导航到 `remoteExportCreate`;不导入时不会出现该 namespace。 - 挂载同一次 import 得到的 JS contribution 会提供 endpoint、参数、结果、lookup、Context 和 Zod 反射,并在无需手写 stub 的情况下实体化调用。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 99ffaca7c7..8a646fc177 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -718,7 +718,7 @@ create(agent: Agent, request: CreateGoalRequest): GoalView * @param request - at least one replacement field. * @returns the edited view. */ -edit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView +@Remote('edit') edit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView /** * Pause an active goal and disarm automatic continuation. @@ -726,7 +726,7 @@ edit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView * @param ref - expected current revision. * @returns the paused view. */ -pause(agent: Agent, ref: GoalRef): GoalView +@Remote('pause') pause(agent: Agent, ref: GoalRef): GoalView /** * Resume and arm a stopped goal, or rearm an active goal after a @@ -735,7 +735,7 @@ pause(agent: Agent, ref: GoalRef): GoalView * @param ref - expected current revision. * @returns the active view. */ -resume(agent: Agent, ref: GoalRef): GoalView +@Remote('resume') resume(agent: Agent, ref: GoalRef): GoalView /** * Mark a current non-complete goal complete and disarm it. @@ -743,7 +743,7 @@ resume(agent: Agent, ref: GoalRef): GoalView * @param ref - expected current revision. * @returns the completed view. */ -complete(agent: Agent, ref: GoalRef): GoalView +@Remote('complete') complete(agent: Agent, ref: GoalRef): GoalView /** * Mark an active goal blocked and disarm it. @@ -760,7 +760,7 @@ block(agent: Agent, ref: GoalRef, reason: GoalBlockReason): GoalView * @param ref - expected current revision. * @returns the tombstone ref whose revision is one past the cleared snapshot. */ -clear(agent: Agent, ref: GoalRef): GoalRef +@Remote('clear') clear(agent: Agent, ref: GoalRef): GoalRef /** * Create one Goal through the remote boundary. @@ -769,47 +769,6 @@ clear(agent: Agent, ref: GoalRef): GoalRef * @returns the created Goal identity. */ @Remote('create') remoteExportCreate(agent: Agent, request: CreateGoalRequest): CreateGoalResult - -/** - * Edit one Goal through the remote boundary. - * @param agent - exact live Agent resolved from the wire identity. - * @param ref - expected current revision. - * @param request - replacement fields. - * @returns the edited Goal view. - */ -@Remote('edit') remoteExportEdit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView - -/** - * Pause one Goal through the remote boundary. - * @param agent - exact live Agent resolved from the wire identity. - * @param ref - expected current revision. - * @returns the paused Goal view. - */ -@Remote('pause') remoteExportPause(agent: Agent, ref: GoalRef): GoalView - -/** - * Resume one Goal through the remote boundary. - * @param agent - exact live Agent resolved from the wire identity. - * @param ref - expected current revision. - * @returns the resumed Goal view. - */ -@Remote('resume') remoteExportResume(agent: Agent, ref: GoalRef): GoalView - -/** - * Complete one Goal through the remote boundary. - * @param agent - exact live Agent resolved from the wire identity. - * @param ref - expected current revision. - * @returns the completed Goal view. - */ -@Remote('complete') remoteExportComplete(agent: Agent, ref: GoalRef): GoalView - -/** - * Clear one terminal Goal through the remote boundary. - * @param agent - exact live Agent resolved from the wire identity. - * @param ref - expected current revision. - * @returns the committed clear revision. - */ -@Remote('clear') remoteExportClear(agent: Agent, ref: GoalRef): GoalRef ``` Types: [Agent](../core-data-structures/core.md) · [CreateGoalRequest](../core-data-structures/goal.md) · [CreateGoalResult](../core-data-structures/goal.md) · [EditGoalRequest](../core-data-structures/goal.md) · [GoalBlockReason](../core-data-structures/goal.md) · [GoalRef](../core-data-structures/goal.md) · [GoalView](../core-data-structures/goal.md) diff --git a/packages/client/remotes/tests/built-lib.e2e.ts b/packages/client/remotes/tests/built-lib.e2e.ts index bef3f4ad65..0cee3eb245 100644 --- a/packages/client/remotes/tests/built-lib.e2e.ts +++ b/packages/client/remotes/tests/built-lib.e2e.ts @@ -148,11 +148,17 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { invalidRejected = true } const rootResult = await client.api.goals.create(rootAgent.id, { objective: 'root goal' }) + const rootEdit = await client.api.goals.edit( + rootAgent.id, + rootResult.ref, + { objective: 'edited root goal' }, + ) const agentContext = client.extend({ builtAgentId: scopedAgent.id }) const scopedResult = await agentContext.goals.create({ objective: 'scoped goal', maxGoalRounds: 3 }) const result = { invalidRejected, rootResult, + rootEdit, scopedResult, rootGoal: host.goals.get(rootAgent)?.objective, scopedGoal: host.goals.get(scopedAgent)?.objective, @@ -174,6 +180,7 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { const output = JSON.parse(result.stdout.trim().split('\n').at(-1) ?? '{}') as { invalidRejected: boolean rootResult: { ref: { id: string; revision: number } } + rootEdit: { objective: string; revision: number } scopedResult: { ref: { id: string; revision: number } } rootGoal: string scopedGoal: string @@ -183,10 +190,11 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { expect(output).toMatchObject({ invalidRejected: true, rootResult: { ref: { revision: 1 } }, + rootEdit: { objective: 'edited root goal', revision: 2 }, scopedResult: { ref: { revision: 1 } }, - rootGoal: 'root goal', + rootGoal: 'edited root goal', scopedGoal: 'scoped goal', - rootEvents: 1, + rootEvents: 2, scopedEvents: 1, }) expect(output.rootResult.ref.id).toMatch(/^goal-/) diff --git a/packages/client/ui-goal/README.i18n.yaml b/packages/client/ui-goal/README.i18n.yaml index 5120d720cd..f30f14ed48 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: 0ea00b8bf9b07f02b5df0f7b3e7d3d9c6f109fde -README.zh.md: 70bf443118e5d2b1ce46e7bc1479bf932507b3f9 +README.md: b99aaf624a7d669879ba668938ee455e3cdc68ad +README.zh.md: 3d823d013066bc912398f61c85553887e05ca3b4 diff --git a/packages/client/ui-goal/README.md b/packages/client/ui-goal/README.md index 0ea00b8bf9..b99aaf624a 100644 --- a/packages/client/ui-goal/README.md +++ b/packages/client/ui-goal/README.md @@ -2,13 +2,13 @@ English | [中文](README.zh.md) -Goal surface plugin, browser half: the `GoalBar` strip is the second standalone card in the `conversation.input.dock` composer-context stack (order 10, after Todo and before Queue). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no domain store, refresh chain, or event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear over the `goal.*` wire domain — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the settled RPC error inline. The strip single-flights mutations synchronously because React's pending render cannot fence same-frame clicks; after a successful clear it immediately suppresses that exact goal id while the authoritative null projection catches up. Goal creation stays on the `/goal` host command; loading, absent, completed, and successfully cleared goals render nothing. +Goal surface plugin, browser half: the `GoalBar` strip is the second standalone card in the `conversation.input.dock` composer-context stack (order 10, after Todo and before Queue). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no domain store, refresh chain, or event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear through `ctx.api.goals` — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the rejected Remote error inline. The strip single-flights mutations synchronously because React's pending render cannot fence same-frame clicks; after a successful clear it immediately suppresses that exact goal id while the authoritative null projection catches up. Goal creation stays on the `/goal` host command; loading, absent, completed, and successfully cleared goals render nothing. The `/client` export surface is the plugin body (`apply`/`inject`), the `GoalBar`/`GoalDock` components, and the injected verb face types. ## Model Experience -Indirectly, through the `goal.edit`/`goal.pause`/`goal.resume`/`goal.clear` RPCs the strip's verbs submit: each accepted mutation commits in a durable `agent/inbox/spliced` insertion, which the goal projection folds immediately, and queues a `goal/change` context message. The model sees that context only if a later pre-step admits it; discarding the queued message does not roll back the projected state. The strip itself adds no prompt content. +Indirectly, through the `goals/edit`, `goals/pause`, `goals/resume`, and `goals/clear` Remote methods the strip invokes: each accepted mutation commits in a durable `agent/inbox/spliced` insertion, which the goal projection folds immediately, and queues a `goal/change` context message. The model sees that context only if a later pre-step admits it; discarding the queued message does not roll back the projected state. The strip itself adds no prompt content. #### KV Cache effect diff --git a/packages/client/ui-goal/README.zh.md b/packages/client/ui-goal/README.zh.md index 70bf443118..3d823d0130 100644 --- a/packages/client/ui-goal/README.zh.md +++ b/packages/client/ui-goal/README.zh.md @@ -2,13 +2,13 @@ [English](README.md) | 中文 -Goal 界面插件(浏览器端部分):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第二张独立卡片(order 10,位于 Todo 之后、Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有领域 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词(edit / pause / resume / clear,走 `goal.*` 协议域——active 的 goal 提供暂停动作,paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref,并把结算后的 RPC 错误内联呈现。由于 React 的 pending 渲染无法拦住同一帧内的点击,横条会同步为变更建立 single-flight 防护;清除成功后,会立即抑制该 goal id 对应的目标显示,直到权威的 null 投影追上。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成和已成功清除的 goal 一律不渲染。 +Goal 界面插件(浏览器端部分):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第二张独立卡片(order 10,位于 Todo 之后、Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有领域 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词(edit / pause / resume / clear,经 `ctx.api.goals` 调用——active 的 goal 提供暂停动作,paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref,并将 Remote 调用的拒绝错误内联呈现。由于 React 的 pending 渲染无法拦住同一帧内的点击,横条会同步为变更建立 single-flight 防护;清除成功后,会立即抑制该 goal id 对应的目标显示,直到权威的 null 投影追上。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成和已成功清除的 goal 一律不渲染。 `/client` 的导出接口包括插件本体(`apply`/`inject`)、`GoalBar`/`GoalDock` 组件与注入动词面类型。 ## 模型体验 -间接影响:条带动词提交的 `goal.edit`/`goal.pause`/`goal.resume`/`goal.clear` RPC 每次被接受后,变更都会在持久 `agent/inbox/spliced` 插入项中提交,goal 投影会立即折叠该插入项,同时将一条 `goal/change` 上下文消息排队。只有后续 pre-step 准入该上下文时,模型才会看到它;丢弃已排队的消息不会回滚投影状态。条带自身不添加任何提示词内容。 +间接影响:条带通过调用 `goals/edit`、`goals/pause`、`goals/resume` 和 `goals/clear` Remote 方法提交变更;每次被接受的变更都会在持久 `agent/inbox/spliced` 插入项中提交,goal 投影会立即折叠该插入项,同时将一条 `goal/change` 上下文消息排队。只有后续 pre-step 准入该上下文时,模型才会看到它;丢弃已排队的消息不会回滚投影状态。条带自身不添加任何提示词内容。 #### KV Cache 影响 diff --git a/packages/client/ui-goal/package.json b/packages/client/ui-goal/package.json index 9430da812f..4c26405bd8 100644 --- a/packages/client/ui-goal/package.json +++ b/packages/client/ui-goal/package.json @@ -25,6 +25,7 @@ "dshClient": { "inject": [ "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-remotes", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-conversation" ], @@ -36,8 +37,8 @@ }, "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-client-connection": "^0.0.1", "@deepseek-ai/dsh-client-locale": "^0.0.1", + "@deepseek-ai/dsh-client-remotes": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", @@ -48,8 +49,8 @@ "react": "^18.2.0" }, "devDependencies": { - "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-remotes": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", diff --git a/packages/client/ui-goal/src/client/index.ts b/packages/client/ui-goal/src/client/index.ts index 6ee340715c..19b88139b5 100644 --- a/packages/client/ui-goal/src/client/index.ts +++ b/packages/client/ui-goal/src/client/index.ts @@ -4,19 +4,19 @@ * arrives through `useProjection('goal')` (seeded by the history tail page, * updated by session/projection frames), so this plugin owns no store, no * refresh chain, and no event listener. The inject face carries only the - * three mutation verbs (edit/resume/clear over the goal.* wire domain); + * four mutation verbs through the generated Goal Remote API; * their CAS ref reads the session's current projected value at call time. * Goal creation stays on the /goal host command. */ -import type { ConnectionHandle, GoalRef, SessionId } from '@deepseek-ai/dsh-client-connection/client' -import type { RpcResult } from '@deepseek-ai/dsh-client-connection/client' -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +// Type-only: pulls the generated Remote API and ctx.api merge through the Client assembly boundary. +import type {} from '@deepseek-ai/dsh-client-remotes/client' // Type-only: pulls the ui-conversation SlotMap merge (the input.dock entry). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' // Type-only: the `goal` SessionProjectionMap key merge (single source, the domain's pure outlet). -import type { GoalProjection } from '@deepseek-ai/dsh-goal/client' +import type { GoalProjection, GoalRef } from '@deepseek-ai/dsh-goal/client' import type { GoalActionResult, GoalBarActions } from './slots.ts' import { GoalDock } from './GoalBar.tsx' import { en, zh, type GoalKey } from './locales.ts' @@ -35,13 +35,32 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Dictionary namespace owned by this plugin. */ const NS = 'goal' -/** Required services: slots for the dock entry, sessions for the projected ref, connection for the wire verbs, locale for the copy. */ -export const inject = ['slots', 'sessions', 'connection', 'locale'] +/** Required services: slots for the dock entry, sessions for the projected ref, API for Remote mutations, locale for the copy. */ +export const inject = ['slots', 'sessions', 'api', 'locale'] -/** Map one settled RPC result onto the strip's inline-render shape. */ -function settle(result: RpcResult): GoalActionResult { - if (result.ok) return { ok: true } - return { ok: false, error: { code: result.error.code, message: result.error.message } } +/** Map one generated Remote call onto the strip's inline-render shape. */ +async function settle(result: Promise): Promise { + try { + await result + return { ok: true } + } catch (error) { + const cause = error instanceof Error ? error.cause : undefined + if (isRemoteError(cause)) return { ok: false, error: { code: cause.code, message: cause.message } } + return { + ok: false, + error: { + code: 'internal', + message: error instanceof Error ? error.message : 'goal mutation failed', + }, + } + } +} + +function isRemoteError(value: unknown): value is { readonly code: string; readonly message: string } { + return value !== null + && typeof value === 'object' + && typeof (value as { code?: unknown }).code === 'string' + && typeof (value as { message?: unknown }).message === 'string' } /** @@ -51,7 +70,7 @@ function settle(result: RpcResult): GoalActionResult { export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-goal: dictionaries') - const { goals } = (ctx.get('connection') as ConnectionHandle).api + const { goals } = ctx.api const sessions = ctx.sessions @@ -77,22 +96,22 @@ export function apply(ctx: ClientContext): void { onEdit: async (objective) => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle((await goals.edit({ sessionId, ref, objective })).result) + return settle(goals.edit(sessionId, ref, { objective })) }, onPause: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle((await goals.pause({ sessionId, ref })).result) + return settle(goals.pause(sessionId, ref)) }, onResume: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle((await goals.resume({ sessionId, ref })).result) + return settle(goals.resume(sessionId, ref)) }, onClear: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle((await goals.clear({ sessionId, ref })).result) + return settle(goals.clear(sessionId, ref)) }, }), }, GoalDock)) diff --git a/packages/client/ui-goal/tests/browser-plugin.spec.tsx b/packages/client/ui-goal/tests/browser-plugin.spec.tsx index 98d5a0291a..eddb272be4 100644 --- a/packages/client/ui-goal/tests/browser-plugin.spec.tsx +++ b/packages/client/ui-goal/tests/browser-plugin.spec.tsx @@ -1,11 +1,11 @@ // @vitest-environment jsdom /** - * ui-goal browser half on a real cordis Context with fake slots/connection/ + * ui-goal browser half on a real cordis Context with fake slots/api/ * sessions faces: the plugin registers the GoalBar dock entry at - * conversation.input.dock, the inject face's three verbs read the CAS ref + * conversation.input.dock, the inject face's four verbs read the CAS ref * from the session's CURRENT projected value at call time (no fence — the - * RPC's compare-and-set is the guard), a missing projection short-circuits - * to the no-current-goal error without touching the wire, and RPC errors + * Remote method's compare-and-set is the guard), a missing projection short-circuits + * to the no-current-goal error without touching the wire, and Remote errors * map onto the inline-render result shape. Registration disposal rides the * plugin fiber (HMR safety). The node half and the invariant companion are * exercised over the same Context. @@ -44,27 +44,32 @@ function makeProjection(revision = 3): GoalProjection { } } -/** Boot the plugin over fake faces; goals verbs record payloads and answer per the script. */ -async function bench(options: { projection?: GoalProjection | null | undefined; failWith?: { code: string; message: string } } = {}) { +/** Boot the plugin over fake faces; Goal Remote methods record arguments and answer per the script. */ +async function bench(options: { + projection?: GoalProjection | null | undefined + failWith?: { code: string; message: string } + rejectWith?: unknown +} = {}) { const ctx = new Context() - const calls: { method: string; payload: unknown }[] = [] + const calls: { method: string; args: unknown[] }[] = [] function answer(method: string, value: T) { - return (payload: unknown) => { - calls.push({ method, payload }) - return Promise.resolve({ - result: options.failWith === undefined - ? { ok: true as const, value } - : { ok: false as const, error: { ...options.failWith, details: {} } }, - }) + return (...args: unknown[]) => { + calls.push({ method, args }) + // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the defensive scenario under test. + if ('rejectWith' in options) return Promise.reject(options.rejectWith) + if (options.failWith !== undefined) { + return Promise.reject(new Error(`Remote ${method} failed`, { cause: options.failWith })) + } + return Promise.resolve(value) } } const ref = { id: 'g-1', revision: 3 } - ctx.provide('connection', { api: { goals: { - edit: answer('goal.edit', { ref }), - pause: answer('goal.pause', { ref }), - resume: answer('goal.resume', { ref }), - clear: answer('goal.clear', { cleared: true as const }), - } } }) + ctx.provide('api', { goals: { + edit: answer('goals/edit', { ref }), + pause: answer('goals/pause', { ref }), + resume: answer('goals/resume', { ref }), + clear: answer('goals/clear', ref), + } }) await ctx.plugin(SlotsService).await() ctx.slots.register({ name: 'root', children: { 'conversation.input.dock': { kind: 'list', scope: 'session' } }, @@ -113,12 +118,12 @@ describe('ui-goal browser plugin', () => { expect(await verbs.onPause()).toEqual({ ok: true }) expect(await verbs.onResume()).toEqual({ ok: true }) expect(await verbs.onClear()).toEqual({ ok: true }) - expect(b.calls.map(c => c.method)).toEqual(['goal.edit', 'goal.pause', 'goal.resume', 'goal.clear']) + expect(b.calls.map(c => c.method)).toEqual(['goals/edit', 'goals/pause', 'goals/resume', 'goals/clear']) const ref = { id: 'g-1', revision: 5 } - expect(b.calls[0]?.payload).toEqual({ sessionId: 's1', ref, objective: 'New objective' }) - expect(b.calls[1]?.payload).toEqual({ sessionId: 's1', ref }) - expect(b.calls[2]?.payload).toEqual({ sessionId: 's1', ref }) - expect(b.calls[3]?.payload).toEqual({ sessionId: 's1', ref }) + expect(b.calls[0]?.args).toEqual(['s1', ref, { objective: 'New objective' }]) + expect(b.calls[1]?.args).toEqual(['s1', ref]) + expect(b.calls[2]?.args).toEqual(['s1', ref]) + expect(b.calls[3]?.args).toEqual(['s1', ref]) }) it('a null or absent projection short-circuits every verb without touching the wire', async () => { @@ -133,13 +138,26 @@ describe('ui-goal browser plugin', () => { } }) - it('maps a settled RPC error onto the inline-render shape', async () => { + it('maps a Remote error onto the inline-render shape', async () => { const b = await bench({ projection: makeProjection(), failWith: { code: 'internal', message: 'stale revision' } }) await b.fiber.await() const verbs = b.entry()!.inject!(sid('s1')) expect(await verbs.onEdit('x')).toEqual({ ok: false, error: { code: 'internal', message: 'stale revision' } }) }) + it.each([ + [new Error('connection closed'), 'connection closed'], + ['connection closed', 'goal mutation failed'], + [new Error('invalid Remote failure', { cause: null }), 'invalid Remote failure'], + [new Error('invalid Remote failure', { cause: { code: 1, message: 'stale revision' } }), 'invalid Remote failure'], + [new Error('invalid Remote failure', { cause: { code: 'internal', message: 1 } }), 'invalid Remote failure'], + ])('maps an unstructured rejection onto an internal error', async (rejection, message) => { + const b = await bench({ projection: makeProjection(), rejectWith: rejection }) + await b.fiber.await() + const verbs = b.entry()!.inject!(sid('s1')) + expect(await verbs.onEdit('x')).toEqual({ ok: false, error: { code: 'internal', message } }) + }) + it('drops the dock entry when the plugin fiber unloads (HMR safety)', async () => { const b = await bench() await b.fiber.await() diff --git a/packages/client/ui-goal/tsconfig.json b/packages/client/ui-goal/tsconfig.json index ad863bdc32..2bb4070b18 100644 --- a/packages/client/ui-goal/tsconfig.json +++ b/packages/client/ui-goal/tsconfig.json @@ -12,10 +12,10 @@ "path": "../../../vendor/cordis" }, { - "path": "../connection" + "path": "../locale" }, { - "path": "../locale" + "path": "../remotes" }, { "path": "../runtime" diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index d8d067ce3e..2627d43b69 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -359,19 +359,19 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * Create and arm a goal. A completed goal may be replaced; every other\n * current phase must be cleared or resumed instead.\n * @param agent - owning live agent.\n * @param request - objective and optional round cap.\n * @returns the created live view.\n */', }, { - signature: 'edit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView', + signature: '@Remote(\'edit\') edit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView', jsDoc: '/**\n * Edit objective and/or round cap without changing phase.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @param request - at least one replacement field.\n * @returns the edited view.\n */', }, { - signature: 'pause(agent: Agent, ref: GoalRef): GoalView', + signature: '@Remote(\'pause\') pause(agent: Agent, ref: GoalRef): GoalView', jsDoc: '/**\n * Pause an active goal and disarm automatic continuation.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the paused view.\n */', }, { - signature: 'resume(agent: Agent, ref: GoalRef): GoalView', + signature: '@Remote(\'resume\') resume(agent: Agent, ref: GoalRef): GoalView', jsDoc: '/**\n * Resume and arm a stopped goal, or rearm an active goal after a\n * session-start edge, while its round budget still has capacity.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the active view.\n */', }, { - signature: 'complete(agent: Agent, ref: GoalRef): GoalView', + signature: '@Remote(\'complete\') complete(agent: Agent, ref: GoalRef): GoalView', jsDoc: '/**\n * Mark a current non-complete goal complete and disarm it.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the completed view.\n */', }, { @@ -379,33 +379,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * Mark an active goal blocked and disarm it.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @param reason - policy-owned stable code and human-readable explanation.\n * @returns the blocked view with its durable reason.\n */', }, { - signature: 'clear(agent: Agent, ref: GoalRef): GoalRef', + signature: '@Remote(\'clear\') clear(agent: Agent, ref: GoalRef): GoalRef', jsDoc: '/**\n * Clear the current goal while retaining a durable tombstone and history.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the tombstone ref whose revision is one past the cleared snapshot.\n */', }, { signature: '@Remote(\'create\') remoteExportCreate(agent: Agent, request: CreateGoalRequest): CreateGoalResult', jsDoc: '/**\n * Create one Goal through the remote boundary.\n * @param agent - exact live Agent resolved from the wire identity.\n * @param request - objective and optional round cap.\n * @returns the created Goal identity.\n */', }, - { - signature: '@Remote(\'edit\') remoteExportEdit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView', - jsDoc: '/**\n * Edit one Goal through the remote boundary.\n * @param agent - exact live Agent resolved from the wire identity.\n * @param ref - expected current revision.\n * @param request - replacement fields.\n * @returns the edited Goal view.\n */', - }, - { - signature: '@Remote(\'pause\') remoteExportPause(agent: Agent, ref: GoalRef): GoalView', - jsDoc: '/**\n * Pause one Goal through the remote boundary.\n * @param agent - exact live Agent resolved from the wire identity.\n * @param ref - expected current revision.\n * @returns the paused Goal view.\n */', - }, - { - signature: '@Remote(\'resume\') remoteExportResume(agent: Agent, ref: GoalRef): GoalView', - jsDoc: '/**\n * Resume one Goal through the remote boundary.\n * @param agent - exact live Agent resolved from the wire identity.\n * @param ref - expected current revision.\n * @returns the resumed Goal view.\n */', - }, - { - signature: '@Remote(\'complete\') remoteExportComplete(agent: Agent, ref: GoalRef): GoalView', - jsDoc: '/**\n * Complete one Goal through the remote boundary.\n * @param agent - exact live Agent resolved from the wire identity.\n * @param ref - expected current revision.\n * @returns the completed Goal view.\n */', - }, - { - signature: '@Remote(\'clear\') remoteExportClear(agent: Agent, ref: GoalRef): GoalRef', - jsDoc: '/**\n * Clear one terminal Goal through the remote boundary.\n * @param agent - exact live Agent resolved from the wire identity.\n * @param ref - expected current revision.\n * @returns the committed clear revision.\n */', - }, ], }, { diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index 312e3a70d9..6667463d86 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -273,6 +273,7 @@ export class GoalService extends GatewayService { * @param request - at least one replacement field. * @returns the edited view. */ + @Remote('edit') edit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView { const cache = this.prepareMutation(agent) const current = this.expectCurrent(cache, ref) @@ -294,6 +295,7 @@ export class GoalService extends GatewayService { * @param ref - expected current revision. * @returns the paused view. */ + @Remote('pause') pause(agent: Agent, ref: GoalRef): GoalView { return this.transition(agent, ref, 'pause', ['active'], 'paused', 'disarmed') } @@ -305,6 +307,7 @@ export class GoalService extends GatewayService { * @param ref - expected current revision. * @returns the active view. */ + @Remote('resume') resume(agent: Agent, ref: GoalRef): GoalView { const cache = this.prepareMutation(agent) const current = this.expectCurrent(cache, ref) @@ -330,6 +333,7 @@ export class GoalService extends GatewayService { * @param ref - expected current revision. * @returns the completed view. */ + @Remote('complete') complete(agent: Agent, ref: GoalRef): GoalView { return this.transition( agent, @@ -369,6 +373,7 @@ export class GoalService extends GatewayService { * @param ref - expected current revision. * @returns the tombstone ref whose revision is one past the cleared snapshot. */ + @Remote('clear') clear(agent: Agent, ref: GoalRef): GoalRef { const cache = this.prepareMutation(agent) const current = this.expectCurrent(cache, ref) @@ -582,62 +587,6 @@ export class GoalService extends GatewayService { const view = this.create(agent, request) return { ref: { id: view.id, revision: view.revision } } } - - /** - * Edit one Goal through the remote boundary. - * @param agent - exact live Agent resolved from the wire identity. - * @param ref - expected current revision. - * @param request - replacement fields. - * @returns the edited Goal view. - */ - @Remote('edit') - remoteExportEdit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView { - return this.edit(agent, ref, request) - } - - /** - * Pause one Goal through the remote boundary. - * @param agent - exact live Agent resolved from the wire identity. - * @param ref - expected current revision. - * @returns the paused Goal view. - */ - @Remote('pause') - remoteExportPause(agent: Agent, ref: GoalRef): GoalView { - return this.pause(agent, ref) - } - - /** - * Resume one Goal through the remote boundary. - * @param agent - exact live Agent resolved from the wire identity. - * @param ref - expected current revision. - * @returns the resumed Goal view. - */ - @Remote('resume') - remoteExportResume(agent: Agent, ref: GoalRef): GoalView { - return this.resume(agent, ref) - } - - /** - * Complete one Goal through the remote boundary. - * @param agent - exact live Agent resolved from the wire identity. - * @param ref - expected current revision. - * @returns the completed Goal view. - */ - @Remote('complete') - remoteExportComplete(agent: Agent, ref: GoalRef): GoalView { - return this.complete(agent, ref) - } - - /** - * Clear one terminal Goal through the remote boundary. - * @param agent - exact live Agent resolved from the wire identity. - * @param ref - expected current revision. - * @returns the committed clear revision. - */ - @Remote('clear') - remoteExportClear(agent: Agent, ref: GoalRef): GoalRef { - return this.clear(agent, ref) - } } export default GoalService diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 2dd5885cc7..3c642d2a8c 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -245,14 +245,14 @@ describe('GoalService creation and replay', () => { }) describe('GoalService mutations', () => { - it('exposes the supported mutation sequence through Remote wrappers', async () => { + it('adapts Remote creation and reuses business methods for later mutations', async () => { const { ctx, agent } = await harness() const created = ctx.goals.remoteExportCreate(agent, { objective: 'remote lifecycle' }) - const edited = ctx.goals.remoteExportEdit(agent, created.ref, { objective: 'edited remotely' }) - const paused = ctx.goals.remoteExportPause(agent, edited) - const resumed = ctx.goals.remoteExportResume(agent, paused) - const completed = ctx.goals.remoteExportComplete(agent, resumed) - const cleared = ctx.goals.remoteExportClear(agent, completed) + const edited = ctx.goals.edit(agent, created.ref, { objective: 'edited remotely' }) + const paused = ctx.goals.pause(agent, edited) + const resumed = ctx.goals.resume(agent, paused) + const completed = ctx.goals.complete(agent, resumed) + const cleared = ctx.goals.clear(agent, completed) expect(edited).toMatchObject({ objective: 'edited remotely', revision: 2 }) expect(paused).toMatchObject({ phase: 'paused', revision: 3 }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9a4453a61..79d38a43cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1617,12 +1617,12 @@ importers: packages/client/ui-goal: devDependencies: - '@deepseek-ai/dsh-client-connection': - specifier: workspace:^ - version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale + '@deepseek-ai/dsh-client-remotes': + specifier: workspace:^ + version: link:../remotes '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime From 2e1f9a5ceaf1c801f018791c326ea29fe42caf3d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:05:55 +0800 Subject: [PATCH 074/104] fix(typert): address remote gateway review --- ...08-02-typert-remote-method-calls.i18n.yaml | 4 +- .../2026-08-02-typert-remote-method-calls.md | 2 +- ...026-08-02-typert-remote-method-calls.zh.md | 2 +- packages/host/api-gateway/src/client/index.ts | 51 +++++++--- .../host/api-gateway/tests/client.spec.ts | 18 ++++ packages/typert/generator/package.json | 1 + packages/typert/generator/src/analyzer.ts | 39 ++++---- packages/typert/generator/src/emitter.ts | 9 +- .../typert/generator/src/tsdown-plugin.ts | 11 ++- packages/typert/generator/src/workspace.ts | 20 ++-- .../fixtures/remote-model/type-meta.d.ts | 8 +- .../generator/tests/remote-model.spec.ts | 97 ++++++++++++++++++- .../generator/tests/tsdown-plugin.spec.ts | 33 ++++++- packages/typert/generator/tsconfig.json | 3 + packages/typert/registry/src/service.ts | 6 +- packages/typert/registry/tests/typert.spec.ts | 8 ++ packages/typert/type-meta/src/index.ts | 15 ++- .../typert/type-meta/tests/type-meta.spec.ts | 3 + pnpm-lock.yaml | 3 + 19 files changed, 275 insertions(+), 58 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index 1a59abbb43..2400e39519 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: c810a221a23549f3e17e25bd40fcc1fc0f9ec868 -2026-08-02-typert-remote-method-calls.zh.md: 38e3ca286dad98665269697f49fba731346e665e +2026-08-02-typert-remote-method-calls.md: 13b407d580c6042a71234e55cdb61225910f0e48 +2026-08-02-typert-remote-method-calls.zh.md: 434cf4765d2206f3c6f99b67c156b9508d70f313 diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index c810a221a2..13b407d580 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -345,7 +345,7 @@ SRC supports local source startup. The `WeakMap` records created by `@Remote` an For example, `@Remote('create') remoteExportCreate(agent, request, signal)` resolves to the external method `create`, implementation member `remoteExportCreate`, two top-level business parameters, and one cancellation injection point. Lookup registration rewrites `agent` to the wire field `agentId`, `request` is passed as a same-named JSON parameter, and the final `signal` stays outside the payload. SRC does not start a `ts.Program`, use a preload or loader hook, generate or rewrite source, or inspect the internal structure of an ordinary JSON object. -A signature that SRC cannot resolve unambiguously fails when the Service mounts. It does not guess at object destructuring, ambiguity caused by default parameters, rest parameters, nested lookups, or complex types. +A signature that SRC cannot resolve unambiguously fails on the first invocation that resolves its descriptor; Service mounting records only the decorator marker and does not inspect the JavaScript signature. SRC does not guess at object destructuring, ambiguity caused by default parameters, rest parameters, nested lookups, or complex types. LIB supports CI, releases, and the prerequisite Web build. TypeRT scans the complete Host project and checks Remote decorators, explicit bindings, service keys, endpoint conflicts, lookup/Context declarations, public-symbol reachability, JSON codecs, result codecs, and that a reserved final `signal` parameter has the global `AbortSignal` type, then generates strict descriptors. diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index 38e3ca286d..434cf4765d 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -345,7 +345,7 @@ SRC 面向本地源码启动。`@Remote` 和 `@RemoteContext()` 的 WeakMap 记 例如 `@Remote('create') remoteExportCreate(agent, request, signal)` 解析为外部方法 `create`、实现成员 `remoteExportCreate`、两个顶层业务参数和一个取消注入点;lookup 注册把 `agent` 改写为 wire 字段 `agentId`,`request` 按同名 JSON 参数传递,最后一个 `signal` 则留在 payload 之外。SRC 不启动 `ts.Program`,不使用 preload、loader hook、源码生成或模块改写,也不检查普通 JSON 对象的内部结构。 -SRC 无法明确解析的签名在 Service 挂载时失败。对象解构、默认参数造成的歧义、rest 参数、嵌套 lookup 和复杂类型不做猜测。 +SRC 无法明确解析的签名会在首次调用解析其 descriptor 时失败;Service 挂载只记录 decorator 标记,不检查 JavaScript 签名。SRC 不会猜测对象解构、默认参数造成的歧义、rest 参数、嵌套 lookup 或复杂类型。 LIB 面向 CI、发布和 Web 前置构建。TypeRT 扫描完整 Host project,检查 Remote decorator、显式 binding、service key、endpoint 冲突、lookup/Context 声明、公共符号可达性、JSON codec、结果 codec,以及保留的最后一个 `signal` 参数是否具有全局 `AbortSignal` 类型,并生成严格 descriptor。 diff --git a/packages/host/api-gateway/src/client/index.ts b/packages/host/api-gateway/src/client/index.ts index 292df54152..3fc8389079 100644 --- a/packages/host/api-gateway/src/client/index.ts +++ b/packages/host/api-gateway/src/client/index.ts @@ -134,8 +134,11 @@ class ClientApiService extends Service implements ClientApi { const record = this.scoped.get(namespace) if (record !== undefined) { for (const method of methods) record.service.assertMethodAvailable(method) - } else if (this.ownerCtx.reflect.props[namespace] !== undefined) { - throw new Error(`client api: scoped namespace ${JSON.stringify(namespace)} conflicts with an existing Context property`) + } else { + for (const method of methods) ScopedRemoteNamespace.assertMethodAvailable(namespace, method) + if (this.ownerCtx.reflect.props[namespace] !== undefined) { + throw new Error(`client api: scoped namespace ${JSON.stringify(namespace)} conflicts with an existing Context property`) + } } } } @@ -143,11 +146,18 @@ class ClientApiService extends Service implements ClientApi { private install(descriptor: InvocationDescriptor): () => void { const token: MountToken = { active: true, abort: new AbortController() } const installed: (() => void)[] = [] - if (descriptor.invocation.kind === 'direct') { - installed.push(this.installDirect(descriptor, token)) + try { + if (descriptor.invocation.kind === 'direct') { + installed.push(this.installDirect(descriptor, token)) + } + const projection = scopedProjection(descriptor) + if (projection !== undefined) installed.push(this.installScoped(descriptor, projection, token)) + } catch (error) { + token.active = false + for (const dispose of installed.reverse()) dispose() + token.abort.abort() + throw error } - const projection = scopedProjection(descriptor) - if (projection !== undefined) installed.push(this.installScoped(descriptor, projection, token)) return () => { /* v8 ignore next -- Cordis effect disposers are idempotent and invoke this cleanup at most once. */ if (!token.active) return @@ -192,19 +202,19 @@ class ClientApiService extends Service implements ClientApi { ): () => void { let namespace = this.scoped.get(descriptor.namespace) if (namespace === undefined) { - namespace = { - service: new ScopedRemoteNamespace( - this.ownerCtx, - descriptor.namespace, - (current, currentProjection, currentToken, caller, args) => - this.invoke(current, currentProjection, currentToken, caller, args), - ), - tokens: new Map(), - } + const service = new ScopedRemoteNamespace( + this.ownerCtx, + descriptor.namespace, + (current, currentProjection, currentToken, caller, args) => + this.invoke(current, currentProjection, currentToken, caller, args), + ) + service.install(descriptor, projection, token) + namespace = { service, tokens: new Map() } this.scoped.set(descriptor.namespace, namespace) + } else { + namespace.service.install(descriptor, projection, token) } namespace.tokens.set(descriptor.method, token) - namespace.service.install(descriptor, projection, token) return () => { /* v8 ignore next -- duplicate live methods are rejected before installation, so no newer token can replace this one. */ if (namespace.tokens.get(descriptor.method) !== token) return @@ -275,6 +285,12 @@ class ScopedRemoteNamespace extends Service { private readonly ownerCtx: Context private readonly methods = new Set() + static assertMethodAvailable(namespace: string, method: string): void { + if (SCOPED_NAMESPACE_FIELDS.has(method) || method in ScopedRemoteNamespace.prototype) { + throw new Error(`client api: scoped method ${JSON.stringify(`${namespace}/${method}`)} conflicts with its namespace service`) + } + } + constructor( ctx: Context, name: string, @@ -285,6 +301,7 @@ class ScopedRemoteNamespace extends Service { } assertMethodAvailable(method: string): void { + ScopedRemoteNamespace.assertMethodAvailable(this.name, method) if (method in this) { throw new Error(`client api: scoped method ${JSON.stringify(`${this.name}/${method}`)} conflicts with its namespace service`) } @@ -311,6 +328,8 @@ class ScopedRemoteNamespace extends Service { } } +const SCOPED_NAMESPACE_FIELDS = new Set(['ctx', 'invokeRemote', 'methods', 'name', 'ownerCtx']) + function endpointOf(descriptor: Pick): string { return `${descriptor.namespace}/${descriptor.method}` } diff --git a/packages/host/api-gateway/tests/client.spec.ts b/packages/host/api-gateway/tests/client.spec.ts index 3ad00ff0fc..28aa848fcd 100644 --- a/packages/host/api-gateway/tests/client.spec.ts +++ b/packages/host/api-gateway/tests/client.spec.ts @@ -279,6 +279,24 @@ describe('Client TypeRT API', () => { await disposeMultipleScoped() }) + it('rolls back direct projection when scoped installation fails', async () => { + const ctx = await bench(vi.fn()) + const descriptor: InvocationDescriptor = { + ...directDescriptor(), + id: '@fixture/goals#fresh/remove', + namespace: 'fresh', + method: 'remove', + } + + for (const packageName of ['@fixture/first-attempt', '@fixture/second-attempt']) { + expect(() => ctx.api.mount({ package: packageName, descriptors: [descriptor] })) + .toThrow('conflicts with its namespace service') + expect((ctx.api as unknown as Record).fresh).toBeUndefined() + expect(ctx.get('fresh')).toBeUndefined() + expect(ctx.typert.remotes.list()).toEqual([]) + } + }) + it('rejects weak parameter and Context codecs plus malformed scope projections', async () => { const ctx = await bench(vi.fn()) const direct = directDescriptor() diff --git a/packages/typert/generator/package.json b/packages/typert/generator/package.json index 5ffb933214..eed553ebed 100644 --- a/packages/typert/generator/package.json +++ b/packages/typert/generator/package.json @@ -30,6 +30,7 @@ ], "license": "BSD-3-Clause", "dependencies": { + "@deepseek-ai/dsh-type-meta": "workspace:^", "@jridgewell/gen-mapping": "^0.3.13", "typescript": "^6.0.3" }, diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index ecc7d8aa6b..5e26245171 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -8,6 +8,7 @@ import { existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs' import { dirname, extname, join, relative, resolve, sep } from 'node:path' import ts from 'typescript' +import { isTypeRTRemoteSegment } from '@deepseek-ai/dsh-type-meta' import type { CrossFaceLink, DocumentationModel, @@ -1171,8 +1172,8 @@ class FaceAnalyzer { namespace = value } } - if (!isRemoteSegment(service)) this.fail(serviceArgument, 'Gateway service key must be nonempty and must not contain "/"') - if (!isRemoteSegment(namespace)) this.fail(options ?? call, 'Gateway namespace must be nonempty and must not contain "/"') + if (!isRemoteSegment(service)) this.fail(serviceArgument, 'Gateway service key must contain only RPC endpoint segment characters') + if (!isRemoteSegment(namespace)) this.fail(options ?? call, 'Gateway namespace must contain only RPC endpoint segment characters') return { service, namespace, site } } @@ -1196,7 +1197,7 @@ class FaceAnalyzer { if (expression.arguments.length !== 1) this.fail(expression, 'Remote() requires one exported method name') const exportName = stringLiteralValue(expression.arguments[0]) if (exportName === undefined || !isRemoteSegment(exportName)) { - this.fail(expression.arguments[0] ?? expression, 'Remote() name must be a nonempty string literal without "/"') + this.fail(expression.arguments[0] ?? expression, 'Remote() name must be a string literal containing only RPC endpoint segment characters') } marker = { kind: 'direct', exportName } } else if (ts.isCallExpression(expression) @@ -1206,12 +1207,12 @@ class FaceAnalyzer { } const context = stringLiteralValue(expression.arguments[0]) if (context === undefined || !isRemoteSegment(context)) { - this.fail(expression.arguments[0] ?? expression, 'RemoteContext() key must be a nonempty string literal without "/"') + this.fail(expression.arguments[0] ?? expression, 'RemoteContext() key must be a string literal containing only RPC endpoint segment characters') } const exportArgument = expression.arguments[1] const exportName = exportArgument === undefined ? undefined : stringLiteralValue(exportArgument) if (exportArgument !== undefined && (exportName === undefined || !isRemoteSegment(exportName))) { - this.fail(exportArgument, 'RemoteContext() name must be a nonempty string literal without "/"') + this.fail(exportArgument, 'RemoteContext() name must be a string literal containing only RPC endpoint segment characters') } marker = { kind: 'context', context, ...exportName === undefined ? {} : { exportName } } } else { @@ -1251,7 +1252,7 @@ class FaceAnalyzer { this.fail(declaration, 'TypeRTLookupMap entries must be required properties') } const key = memberName(declaration.name) - if (!isRemoteSegment(key)) this.fail(declaration.name, 'TypeRTLookupMap key must be nonempty and must not contain "/"') + if (!isRemoteSegment(key)) this.fail(declaration.name, 'TypeRTLookupMap key must contain only RPC endpoint segment characters') if (!ts.isTypeReferenceNode(declaration.type) || !this.isTypeMetaSymbol(declaration.type.typeName, 'TypeRTLookup') || declaration.type.typeArguments?.length !== 2) { @@ -1287,7 +1288,7 @@ class FaceAnalyzer { this.fail(declaration, 'TypeRTContextMap entries must be required properties') } const key = memberName(declaration.name) - if (!isRemoteSegment(key)) this.fail(declaration.name, 'TypeRTContextMap key must be nonempty and must not contain "/"') + if (!isRemoteSegment(key)) this.fail(declaration.name, 'TypeRTContextMap key must contain only RPC endpoint segment characters') if (!ts.isTypeReferenceNode(declaration.type) || !this.isTypeMetaSymbol(declaration.type.typeName, 'TypeRTContext') || declaration.type.typeArguments?.length !== 1) { @@ -1331,16 +1332,6 @@ class FaceAnalyzer { const type = this.convertType(authoredType) const codecType = this.resolvedRemoteCodecType(authoredType) const rootSymbol = this.namedWorkspaceType(authoredType) - if (rootSymbol !== undefined) { - const imported = this.publicRemoteType(rootSymbol, authoredType) - return { - type, - codecType, - typeSymbol: `${imported.specifier}#${imported.name}`, - imports: [imported], - } - } - if (requireNamed) this.fail(authoredType, 'lookup and Context wire types must be named public types') const imports = new Map() const visit = (node: ts.Node): void => { if ((ts.isTypeReferenceNode(node) || ts.isImportTypeNode(node))) { @@ -1355,13 +1346,23 @@ class FaceAnalyzer { && this.registrationForFile(declaration.getSourceFile().fileName) !== undefined) { const imported = this.publicRemoteType(resolved, node) imports.set(imported.symbol, imported) - return } } } ts.forEachChild(node, visit) } visit(authoredType) + if (rootSymbol !== undefined) { + const imported = this.publicRemoteType(rootSymbol, authoredType) + return { + type, + codecType, + typeSymbol: `${imported.specifier}#${imported.name}`, + imports: [...imports.values()].sort((left, right) => + left.specifier.localeCompare(right.specifier) || left.name.localeCompare(right.name)), + } + } + if (requireNamed) this.fail(authoredType, 'lookup and Context wire types must be named public types') return { type, codecType, @@ -2810,7 +2811,7 @@ function stringLiteralValue(node: ts.Node | undefined): string | undefined { } function isRemoteSegment(value: string): boolean { - return value.length > 0 && !value.includes('/') + return isTypeRTRemoteSegment(value) } function expressionName(node: ts.Expression): string | undefined { diff --git a/packages/typert/generator/src/emitter.ts b/packages/typert/generator/src/emitter.ts index c8b9ab4195..bb39959606 100644 --- a/packages/typert/generator/src/emitter.ts +++ b/packages/typert/generator/src/emitter.ts @@ -414,8 +414,9 @@ export class FaceModelEmitter { invocation: InvocationModel, referenceNames: ReadonlyMap, ): void { - const signature = `${invocation.method}: ${this.remoteFunctionType(invocation, referenceNames, false)}` - this.pushMappedRemoteSignature(lines, sourceMap, packageModel, invocation, signature, invocation.method.length) + const key = renderRemotePropertyName(invocation.method) + const signature = `${key}: ${this.remoteFunctionType(invocation, referenceNames, false)}` + this.pushMappedRemoteSignature(lines, sourceMap, packageModel, invocation, signature, key.length) } private pushMappedRemoteSignature( @@ -914,6 +915,10 @@ function safeIdentifier(name: string): string { return `_${normalized}` } +function renderRemotePropertyName(name: string): string { + return /^[$A-Z_a-z][$\w]*$/u.test(name) ? name : quote(name) +} + function quote(value: string): string { return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'").replaceAll('\n', '\\n').replaceAll('\r', '\\r')}'` } diff --git a/packages/typert/generator/src/tsdown-plugin.ts b/packages/typert/generator/src/tsdown-plugin.ts index 10cba60974..eca5ad47d2 100644 --- a/packages/typert/generator/src/tsdown-plugin.ts +++ b/packages/typert/generator/src/tsdown-plugin.ts @@ -6,7 +6,7 @@ * @module @deepseek-ai/dsh-typert-generator/tsdown */ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' import ts from 'typescript' import { WorkspaceTypertGenerator } from './workspace.ts' @@ -103,15 +103,24 @@ export function typertPlugin(pluginOptions: TypertPluginOptions = {}): TypertPlu function emitArtifacts(packageDir: string, artifacts: readonly WorkspaceEmitResult[]): void { const output = join(packageDir, 'lib') mkdirSync(output, { recursive: true }) + let emittedRemote = false for (const artifact of artifacts) { writeFileSync(join(output, `typert.${artifact.face}.js`), artifact.js) writeFileSync(join(output, `typert.${artifact.face}.d.ts`), artifact.dts) if (artifact.remote !== undefined) { + emittedRemote = true writeFileSync(join(output, 'typert.remote-client.js'), artifact.remote.js) writeFileSync(join(output, 'typert.remote-client.d.ts'), artifact.remote.dts) writeFileSync(join(output, 'typert.remote-client.d.ts.map'), artifact.remote.dtsMap) } } + if (!emittedRemote && artifacts.some(artifact => artifact.face === 'host')) { + for (const file of [ + 'typert.remote-client.js', + 'typert.remote-client.d.ts', + 'typert.remote-client.d.ts.map', + ]) rmSync(join(output, file), { force: true }) + } } function readManifest(packageDir: string): { name?: string; exports?: unknown } { diff --git a/packages/typert/generator/src/workspace.ts b/packages/typert/generator/src/workspace.ts index c79861a796..6327872166 100644 --- a/packages/typert/generator/src/workspace.ts +++ b/packages/typert/generator/src/workspace.ts @@ -90,7 +90,6 @@ export class WorkspaceTypertGenerator { throw new TypertAnalysisError(`typert(${artifact.face}): ${artifact.package} package files must include ${file}`) } } - if (artifact.remote === undefined) return const remoteExpected = { types: './lib/typert.remote-client.d.ts', default: './lib/typert.remote-client.js', @@ -98,16 +97,25 @@ export class WorkspaceTypertGenerator { const remoteActual = manifest.exports !== null && typeof manifest.exports === 'object' ? (manifest.exports as Record)['./remote'] : undefined + const remoteFiles = [ + 'lib/typert.remote-client.js', + 'lib/typert.remote-client.d.ts', + 'lib/typert.remote-client.d.ts.map', + ] + if (artifact.remote === undefined) { + if (remoteActual !== undefined || remoteFiles.some(file => files.includes(file))) { + throw new TypertAnalysisError( + `typert(host): ${artifact.package} publishes Remote artifacts but has no Remote methods`, + ) + } + return + } if (!sameExport(remoteActual, remoteExpected)) { throw new TypertAnalysisError( `typert(host): ${artifact.package} must export ./remote as ${JSON.stringify(remoteExpected)}`, ) } - for (const file of [ - 'lib/typert.remote-client.js', - 'lib/typert.remote-client.d.ts', - 'lib/typert.remote-client.d.ts.map', - ]) { + for (const file of remoteFiles) { if (!files.includes(file)) { throw new TypertAnalysisError(`typert(host): ${artifact.package} package files must include ${file}`) } diff --git a/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts b/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts index 91daea98c2..5347a6b77e 100644 --- a/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts +++ b/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts @@ -50,7 +50,13 @@ declare module '@deepseek-ai/dsh-type-meta' { context: ClassMethodDecoratorContext Result>, ): void - export function RemoteContext(key: Extract): + export function Remote(exportName: string): + ( + method: (this: This, ...args: Args) => Result, + context: ClassMethodDecoratorContext Result>, + ) => void + + export function RemoteContext(key: Extract, exportName?: string): ( method: (this: This, ...args: Args) => Result, context: ClassMethodDecoratorContext Result>, diff --git a/packages/typert/generator/tests/remote-model.spec.ts b/packages/typert/generator/tests/remote-model.spec.ts index 268645ca73..4f4f3ea7cb 100644 --- a/packages/typert/generator/tests/remote-model.spec.ts +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -216,6 +216,91 @@ export type GenericResult = { expect(dispatch?.result.schema.safeParse({ kind: 'ship', value: { cancelled: true } }).success).toBe(false) }) + it('imports public type arguments nested under a named generic boundary', () => { + const root = copyFixture() + editFile(root, 'packages/remote/src/types.ts', source => `${source} + +/** Generic Remote envelope. */ +export interface Box { + readonly value: Value +} + +/** Payload reachable only as a generic argument. */ +export interface BoxPayload { + readonly count: number +} +`) + editFile(root, 'packages/remote/src/index.ts', source => source + .replace( + ' RenameGoalResult,\n', + ' RenameGoalResult,\n Box,\n BoxPayload,\n', + ) + .replace( + ' rename(request: RenameGoalRequest): RenameGoalResult {\n return { renamed: request.title.length > 0 }\n }\n}', + ` rename(request: RenameGoalRequest): RenameGoalResult { + return { renamed: request.title.length > 0 } + } + + @Remote + box(request: Box): Box { + return request + } +}`, + )) + + const [artifact] = new WorkspaceTypertGenerator(root).generate() + expect(artifact?.remote?.dts).toMatch(/import type \{ [^}]*Box[^}]*BoxPayload[^}]* \} from '@fixture\/remote\/types'/) + expect(artifact?.remote?.dts).toContain('box: (request: Box) => Promise>') + assertRemoteConsumerTypechecks(artifact?.remote?.dts, artifact?.remote?.dtsMap, root) + }) + + it('quotes aliased methods in generated namespace interfaces', () => { + const root = copyFixture() + editFile(root, 'packages/remote/src/index.ts', source => source.replace( + ' rename(request: RenameGoalRequest): RenameGoalResult {\n return { renamed: request.title.length > 0 }\n }\n}', + ` rename(request: RenameGoalRequest): RenameGoalResult { + return { renamed: request.title.length > 0 } + } + + @Remote('create-goal') + createAlias(request: CreateGoalRequest): CreateGoalResult { + return { ref: request.title } + } +}`, + )) + + const [artifact] = new WorkspaceTypertGenerator(root).generate() + expect(artifact?.remote?.dts).toContain("'create-goal': (request: CreateGoalRequest) => Promise") + assertRemoteConsumerTypechecks(artifact?.remote?.dts, artifact?.remote?.dtsMap, root) + }) + + it.each(['create#v2', 'create goal'])('rejects untransportable Remote alias %s', (alias) => { + const root = copyFixture() + editFile(root, 'packages/remote/src/index.ts', source => source.replace( + ' @Remote\n async create(', + ` @Remote('${alias}')\n async create(`, + )) + + expect(() => analyzeRemote(root, false)).toThrow(/RPC endpoint segment characters/) + }) + + it('rejects a Remote export after its last Remote method is removed', () => { + const root = copyFixture() + editFile(root, 'packages/remote/src/index.ts', source => source + .replace(' @Remote\n', '') + .replace(" @RemoteContext('agent')\n", '')) + editFile(root, 'packages/remote/src/types.ts', source => `${source} + +/** @typert schema */ +export interface RemainingSchema { + readonly value: string +} +`) + + expect(() => new WorkspaceTypertGenerator(root).generate()) + .toThrow('publishes Remote artifacts but has no Remote methods') + }) + it.each([ { name: 'missing binding', @@ -429,9 +514,9 @@ function remotePackage(root: string): { return packageModel } -function copyFixture(): string { +function copyFixture(sourceRoot = fixtureRoot): string { const root = mkdtempSync(join(tmpdir(), 'dsh-typert-remote-model-')) - cpSync(fixtureRoot, root, { recursive: true }) + cpSync(sourceRoot, root, { recursive: true }) temporaryRoots.push(root) return root } @@ -444,10 +529,14 @@ function editFile(root: string, relativePath: string, edit: (source: string) => writeFileSync(path, result) } -function assertRemoteConsumerTypechecks(dts: string | undefined, dtsMap: string | undefined): void { +function assertRemoteConsumerTypechecks( + dts: string | undefined, + dtsMap: string | undefined, + sourceRoot = fixtureRoot, +): void { if (dts === undefined) throw new Error('Remote fixture emitted no Host-for-Client declaration') if (dtsMap === undefined) throw new Error('Remote fixture emitted no Host-for-Client declaration map') - const consumerRoot = copyFixture() + const consumerRoot = copyFixture(sourceRoot) const declarationPath = join(consumerRoot, 'packages/remote/lib/typert.remote-client.d.ts') const declarationMapPath = `${declarationPath}.map` const consumerPath = join(consumerRoot, 'consumer.ts') diff --git a/packages/typert/generator/tests/tsdown-plugin.spec.ts b/packages/typert/generator/tests/tsdown-plugin.spec.ts index 106b8950ff..556e96045d 100644 --- a/packages/typert/generator/tests/tsdown-plugin.spec.ts +++ b/packages/typert/generator/tests/tsdown-plugin.spec.ts @@ -3,8 +3,9 @@ import { mkdir } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' +import type { WorkspaceEmitResult } from '../src/workspace.ts' -const generated = vi.hoisted(() => vi.fn(() => [ +const generated = vi.hoisted(() => vi.fn<() => WorkspaceEmitResult[]>(() => [ { package: '@deepseek-ai/dsh-tools', packageRoot: 'packages/core/tools', @@ -141,6 +142,36 @@ describe('typertPlugin', () => { .toBe('{"version":3}\n') }) + it('removes stale Remote artifacts from a Host package without Remote output', async () => { + const root = await workspace() + const output = await packageOutput(root, 'tools', { + name: '@deepseek-ai/dsh-tools', + exports: { './typert': './lib/typert.host.js' }, + }) + const packageLib = join(root, 'packages', 'tools', 'lib') + for (const file of [ + 'typert.remote-client.js', + 'typert.remote-client.d.ts', + 'typert.remote-client.d.ts.map', + ]) writeFileSync(join(packageLib, file), 'stale\n') + generated.mockReturnValueOnce([{ + package: '@deepseek-ai/dsh-tools', + packageRoot: 'packages/core/tools', + face: 'host', + exports: [], + js: 'export const host = true\n', + dts: 'export declare const host: true\n', + }]) + + typertPlugin().writeBundle({ dir: output }) + + for (const file of [ + 'typert.remote-client.js', + 'typert.remote-client.d.ts', + 'typert.remote-client.d.ts.map', + ]) expect(existsSync(join(packageLib, file))).toBe(false) + }) + it('emits every explicit workspace contributor once from a host-only prepass', async () => { const root = await workspace() const trigger = await packageOutput(root, 'generator', { name: '@deepseek-ai/dsh-typert-generator' }) diff --git a/packages/typert/generator/tsconfig.json b/packages/typert/generator/tsconfig.json index 9966c8ca8a..311dfa4b6d 100644 --- a/packages/typert/generator/tsconfig.json +++ b/packages/typert/generator/tsconfig.json @@ -16,6 +16,9 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../type-meta" } ] } diff --git a/packages/typert/registry/src/service.ts b/packages/typert/registry/src/service.ts index d04f38cde5..7a097b8635 100644 --- a/packages/typert/registry/src/service.ts +++ b/packages/typert/registry/src/service.ts @@ -7,6 +7,7 @@ import { Context, Service } from 'cordis' import { z } from 'zod' +import { isTypeRTRemoteSegment } from '@deepseek-ai/dsh-type-meta' import type { InvocationDescriptor, TypeRTClientContextBinder, @@ -600,8 +601,9 @@ function validateCodec(codec: InvocationDescriptor['result'], subject: string): } function validateWireName(subject: string, value: string): void { - validateSegment(subject, value) - if (value.includes('/')) throw new Error(`typert: invalid ${subject} "${value}" — must not contain "/"`) + if (!isTypeRTRemoteSegment(value)) { + throw new Error(`typert: invalid ${subject} "${value}" — must contain only RPC endpoint segment characters`) + } } function validateSegment(subject: string, value: string): void { diff --git a/packages/typert/registry/tests/typert.spec.ts b/packages/typert/registry/tests/typert.spec.ts index 5603ce8954..6661cbeeb4 100644 --- a/packages/typert/registry/tests/typert.spec.ts +++ b/packages/typert/registry/tests/typert.spec.ts @@ -247,6 +247,14 @@ describe('TypertRegistry', () => { })).toThrow('endpoint "goals/create" is already registered') }) + it.each(['create#v2', 'create goal'])('rejects untransportable invocation method %s', async (method) => { + const ctx = await makeCtx() + expect(() => ctx.typert.remotes.register({ + package: '@fixture/invalid-endpoint', + descriptors: [{ ...invocation(), method }], + })).toThrow('RPC endpoint segment characters') + }) + it('mounts Remote contributions in the calling fiber and withdraws them exactly', async () => { const ctx = await makeCtx() const descriptor = invocation() diff --git a/packages/typert/type-meta/src/index.ts b/packages/typert/type-meta/src/index.ts index 4d4457b5be..67a4169f96 100644 --- a/packages/typert/type-meta/src/index.ts +++ b/packages/typert/type-meta/src/index.ts @@ -7,6 +7,17 @@ import { Service, type Context } from 'cordis' import type { TypeRTContextMap } from './types.ts' +const TYPERT_REMOTE_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/ + +/** + * Test one generated Remote name against the Connection endpoint grammar. + * @param value - namespace, method, lookup, or Context segment. + * @returns whether the value can cross the shared RPC carrier unchanged. + */ +export function isTypeRTRemoteSegment(value: string): boolean { + return TYPERT_REMOTE_SEGMENT_PATTERN.test(value) +} + export type { InvocationDescriptor, InvocationParameterDescriptor, @@ -236,7 +247,7 @@ function sameInvocation(left: RemoteInvocationMarker, right: RemoteInvocationMar } function validateName(subject: string, value: string): void { - if (value.length === 0 || value.includes('/')) { - throw new TypeError(`type-meta: ${subject} must be nonempty and must not contain "/"`) + if (!isTypeRTRemoteSegment(value)) { + throw new TypeError(`type-meta: ${subject} must contain only RPC endpoint segment characters`) } } diff --git a/packages/typert/type-meta/tests/type-meta.spec.ts b/packages/typert/type-meta/tests/type-meta.spec.ts index 8a2a4372ce..757488024d 100644 --- a/packages/typert/type-meta/tests/type-meta.spec.ts +++ b/packages/typert/type-meta/tests/type-meta.spec.ts @@ -162,6 +162,8 @@ describe('type-meta Remote declarations', () => { const method: (this: object) => void = function (this: object): void {} expect(() => { (Remote as unknown as (value: typeof method) => void)(method) }).toThrow('context is missing') expect(() => Remote('bad/name')).toThrow('export name') + expect(() => Remote('bad#name')).toThrow('export name') + expect(() => Remote('bad name')).toThrow('export name') expect(() => RemoteContext('' as 'metaFixture')).toThrow('Context key') expect(() => RemoteContext('metaFixture', 'bad/name')).toThrow('export name') @@ -203,6 +205,7 @@ describe('type-meta Remote declarations', () => { it('rejects ambiguous binding names', () => { expect(() => bindTypeRTGateway({}, '')).toThrow('service key') expect(() => bindTypeRTGateway({}, 'goals', { namespace: 'api/goals' })).toThrow('namespace') + expect(() => bindTypeRTGateway({}, 'goals', { namespace: 'api goals' })).toThrow('namespace') }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 79d38a43cd..d6889328eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6172,6 +6172,9 @@ importers: packages/typert/generator: dependencies: + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../type-meta '@jridgewell/gen-mapping': specifier: ^0.3.13 version: 0.3.13 From 0ad58850155b9eab5fd1f6c000b09f28f1bb0f03 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:13:19 +0800 Subject: [PATCH 075/104] fix(typert): keep generator bootstrap self-contained --- packages/typert/generator/package.json | 1 - packages/typert/generator/src/analyzer.ts | 3 +-- packages/typert/generator/tsconfig.json | 3 --- pnpm-lock.yaml | 3 --- 4 files changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/typert/generator/package.json b/packages/typert/generator/package.json index eed553ebed..5ffb933214 100644 --- a/packages/typert/generator/package.json +++ b/packages/typert/generator/package.json @@ -30,7 +30,6 @@ ], "license": "BSD-3-Clause", "dependencies": { - "@deepseek-ai/dsh-type-meta": "workspace:^", "@jridgewell/gen-mapping": "^0.3.13", "typescript": "^6.0.3" }, diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index 5e26245171..16c30e8bc5 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -8,7 +8,6 @@ import { existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs' import { dirname, extname, join, relative, resolve, sep } from 'node:path' import ts from 'typescript' -import { isTypeRTRemoteSegment } from '@deepseek-ai/dsh-type-meta' import type { CrossFaceLink, DocumentationModel, @@ -2811,7 +2810,7 @@ function stringLiteralValue(node: ts.Node | undefined): string | undefined { } function isRemoteSegment(value: string): boolean { - return isTypeRTRemoteSegment(value) + return /^[A-Za-z0-9_$.-]+$/.test(value) } function expressionName(node: ts.Expression): string | undefined { diff --git a/packages/typert/generator/tsconfig.json b/packages/typert/generator/tsconfig.json index 311dfa4b6d..9966c8ca8a 100644 --- a/packages/typert/generator/tsconfig.json +++ b/packages/typert/generator/tsconfig.json @@ -16,9 +16,6 @@ }, { "path": "../../support/invariants" - }, - { - "path": "../type-meta" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d6889328eb..79d38a43cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6172,9 +6172,6 @@ importers: packages/typert/generator: dependencies: - '@deepseek-ai/dsh-type-meta': - specifier: workspace:^ - version: link:../type-meta '@jridgewell/gen-mapping': specifier: ^0.3.13 version: 0.3.13 From 5ea631194910153c5f89f3eb7a100c012ff72288 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:22:52 +0800 Subject: [PATCH 076/104] test(api-gateway): cover client mount rollback --- .../host/api-gateway/tests/client.spec.ts | 48 ++++++++++++++----- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/packages/host/api-gateway/tests/client.spec.ts b/packages/host/api-gateway/tests/client.spec.ts index 28aa848fcd..5c2c427605 100644 --- a/packages/host/api-gateway/tests/client.spec.ts +++ b/packages/host/api-gateway/tests/client.spec.ts @@ -258,6 +258,13 @@ describe('Client TypeRT API', () => { package: '@fixture/service-method-conflict', descriptors: [{ ...context, id: '@fixture/goals#goals/remove', method: 'remove' }], })).toThrow('conflicts with its namespace service') + const scopedService = ctx.get('goals') as unknown as object + Object.defineProperty(scopedService, 'custom', { configurable: true, value: () => undefined }) + expect(() => ctx.api.mount({ + package: '@fixture/service-own-property-conflict', + descriptors: [{ ...direct, id: '@fixture/goals#goals/custom', method: 'custom' }], + })).toThrow('conflicts with its namespace service') + Reflect.deleteProperty(scopedService, 'custom') await disposeScoped() expect(() => ctx.api.mount({ @@ -281,20 +288,37 @@ describe('Client TypeRT API', () => { it('rolls back direct projection when scoped installation fails', async () => { const ctx = await bench(vi.fn()) - const descriptor: InvocationDescriptor = { - ...directDescriptor(), - id: '@fixture/goals#fresh/remove', - namespace: 'fresh', - method: 'remove', + const disposeScoped = ctx.api.mount({ + package: '@fixture/scoped-base', + descriptors: [contextDescriptor()], + }) + const defineProperty = Object.defineProperty + let createDefinitions = 0 + const definePropertySpy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => { + // The direct projection defines `create` first; fail the following scoped projection. + if (key === 'create' && ++createDefinitions === 2) throw new Error('simulated scoped installation failure') + return defineProperty(target, key, attributes) + }) + + try { + expect(() => ctx.api.mount({ + package: '@fixture/failing-install', + descriptors: [directDescriptor()], + })).toThrow('simulated scoped installation failure') + } finally { + definePropertySpy.mockRestore() } - for (const packageName of ['@fixture/first-attempt', '@fixture/second-attempt']) { - expect(() => ctx.api.mount({ package: packageName, descriptors: [descriptor] })) - .toThrow('conflicts with its namespace service') - expect((ctx.api as unknown as Record).fresh).toBeUndefined() - expect(ctx.get('fresh')).toBeUndefined() - expect(ctx.typert.remotes.list()).toEqual([]) - } + expect((ctx.api as unknown as Record).goals).toBeUndefined() + expect(ctx.get('goals') !== undefined).toBe(true) + expect(ctx.typert.remotes.list()).toHaveLength(1) + + const disposeRetry = ctx.api.mount({ + package: '@fixture/retry', + descriptors: [directDescriptor()], + }) + await disposeRetry() + await disposeScoped() }) it('rejects weak parameter and Context codecs plus malformed scope projections', async () => { From 56af59b41d8a8459d36a8d3e18aa6b455cbf03be Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:52:24 +0800 Subject: [PATCH 077/104] fix(typert): keep client registry bundle pure --- packages/typert/registry/src/service.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/typert/registry/src/service.ts b/packages/typert/registry/src/service.ts index 7a097b8635..2f85138edd 100644 --- a/packages/typert/registry/src/service.ts +++ b/packages/typert/registry/src/service.ts @@ -7,7 +7,6 @@ import { Context, Service } from 'cordis' import { z } from 'zod' -import { isTypeRTRemoteSegment } from '@deepseek-ai/dsh-type-meta' import type { InvocationDescriptor, TypeRTClientContextBinder, @@ -601,7 +600,7 @@ function validateCodec(codec: InvocationDescriptor['result'], subject: string): } function validateWireName(subject: string, value: string): void { - if (!isTypeRTRemoteSegment(value)) { + if (!/^[A-Za-z0-9_$.-]+$/.test(value)) { throw new Error(`typert: invalid ${subject} "${value}" — must contain only RPC endpoint segment characters`) } } From eca3090dfaecfd67ff79e4679f1cf90fa295eccb Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:11:32 +0800 Subject: [PATCH 078/104] fix: docs --- docs/config-catalog.md | 2 -- docs/module-graph.md | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 41c9c98515..77baad512d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2565,8 +2565,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-timeout-policy` — requires `tools` ([`packages/timeout/timeout-policy/src/index.ts`](../packages/timeout/timeout-policy/src/index.ts)) - `@deepseek-ai/dsh-tool-ask-user` — requires `tools` · `userInteraction` ([`packages/ui/tool-ask-user/src/index.ts`](../packages/ui/tool-ask-user/src/index.ts)) - `@deepseek-ai/dsh-tool-subagent-control` — requires `tools` · `subagents` ([`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts)) -- `@deepseek-ai/dsh-tool-todo` — requires `tools` ([`packages/todo/tool-todo/src/index.ts`](../packages/todo/tool-todo/src/index.ts)) -- `@deepseek-ai/dsh-typert-registry` ([`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts)) - `@deepseek-ai/dsh-user-interaction` ([`packages/ui/user-interaction/src/index.ts`](../packages/ui/user-interaction/src/index.ts)) - `@deepseek-ai/dsh-workspace` — requires `storageDomain` · `sessionPersistence` ([`packages/workspace/workspace/src/index.ts`](../packages/workspace/workspace/src/index.ts)) diff --git a/docs/module-graph.md b/docs/module-graph.md index f659e61206..ac46e968b3 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1132,8 +1132,8 @@ flowchart TD pkg_client_ui_command --> pkg_client_ui_slash pkg_client_ui_command --> pkg_client_ui_slots pkg_client_ui_command --> pkg_invariants - pkg_client_ui_goal --> pkg_client_connection pkg_client_ui_goal --> pkg_client_locale + pkg_client_ui_goal --> pkg_client_remotes pkg_client_ui_goal --> pkg_client_runtime pkg_client_ui_goal --> pkg_client_ui_conversation pkg_client_ui_goal --> pkg_client_ui_primitives @@ -1383,7 +1383,7 @@ flowchart TD | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | +| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-locale`](../packages/client/locale), [`client-remotes`](../packages/client/remotes), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`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/support/invariants) | From e28ac506edf9ea5c5c72bdba20fea1e16f708728 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:21:41 +0800 Subject: [PATCH 079/104] perf(api-gateway): cache SRC endpoint claims --- ...08-02-typert-remote-method-calls.i18n.yaml | 4 +- .../2026-08-02-typert-remote-method-calls.md | 4 +- ...026-08-02-typert-remote-method-calls.zh.md | 4 +- docs/event-producer-consumer.md | 1 + packages/host/api-gateway/src/index.ts | 21 ++++++-- .../host/api-gateway/tests/gateway.spec.ts | 49 +++++++++++++++++++ 6 files changed, 73 insertions(+), 10 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index 2400e39519..02e6c428ff 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: 13b407d580c6042a71234e55cdb61225910f0e48 -2026-08-02-typert-remote-method-calls.zh.md: 434cf4765d2206f3c6f99b67c156b9508d70f313 +2026-08-02-typert-remote-method-calls.md: ddc93b4fc672f320b4e3dc3e11586d92604e6aa4 +2026-08-02-typert-remote-method-calls.zh.md: 808c7d54bff19d9a4e9cf924769df1d405d997b5 diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index 13b407d580..ddc93b4fc6 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -158,7 +158,7 @@ ctx.typert.lookups wire ID 到 Host 活对象的 provider ctx.typert.contexts Host Context resolver 与 Client Context binder ``` -Every registration returns a disposer owned by the caller's Cordis fiber. Client contribution mounting registers the descriptor set and concrete methods as one owned operation. The Host Gateway resolves descriptors, Services, and providers from current state for every claim and invocation instead of retaining endpoint registrations. Removing a strict definition, Service, or provider therefore makes the corresponding call unavailable without leaving a stale live object. +Every registration returns a disposer owned by the caller's Cordis fiber. Client contribution mounting registers the descriptor set and concrete methods as one owned operation. The Host Gateway caches only the set of SRC-owned endpoint names and discards it whenever the Cordis Service set changes; it retains no descriptor, Service, or provider. Invocation resolves all live objects from current state, so removing a strict definition, Service, or provider makes the corresponding call unavailable without leaving a stale live object. The lookup registry retains the stable wire declaration after its live resolver unloads. SRC parsing continues to classify the parameter as a lookup, while invocation fails with `lookup-unavailable`; it never reclassifies the incoming ID as an ordinary JSON business object. Re-registering the same key with different parameter, wire, or canonical type symbols fails for the lifetime of that TypeRT Service. @@ -355,7 +355,7 @@ CI and releases use LIB. Moving all repository coverage to LIB is separate follo ## Host Gateway resolution -The Host Gateway registers one `/api` interceptor with Connection and does not maintain a second endpoint registry. Its ownership matcher resolves each endpoint from the current TypeRT local registry or scans current Cordis Services for a matching `typertGateway` binding and SRC Remote marker. TypeRT definitions and business Services may therefore arrive in either order. +The Host Gateway registers one `/api` interceptor with Connection and does not maintain a second endpoint registry. Its ownership matcher checks the current TypeRT local registry first, then consults an invalidation-aware set populated by scanning current Cordis Services for `typertGateway` bindings and SRC Remote markers. A Cordis Service change discards the set, so TypeRT definitions and business Services may arrive in either order without making legacy `/api` traffic rescan every Service on each request or letting arbitrary request paths grow the cache. Invocation resolves the descriptor, receiver, lookup providers, and Context provider again from current state. A current strict descriptor takes precedence over SRC. After a strict endpoint has appeared, `TypeRTLocalRegistry.hasSeen()` keeps it owned when that descriptor is withdrawn and forbids SRC fallback for the remainder of the registry lifetime; re-registering the strict descriptor restores calls. Removing a Service or provider makes invocation fail explicitly, and the Gateway neither retains invalid objects nor invokes a method with a raw lookup ID. diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index 434cf4765d..808c7d54bf 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -158,7 +158,7 @@ ctx.typert.lookups wire ID 到 Host 活对象的 provider ctx.typert.contexts Host Context resolver 与 Client Context binder ``` -每次注册都返回由调用方 Cordis fiber 持有的 disposer。挂载 Client contribution 时,descriptor 集与具体方法会作为一项有明确所有者的操作统一注册。Host Gateway 每次认领和调用时都从当前状态解析 descriptor、Service 与提供方,不保留 endpoint 注册。因此移除 strict definition、Service 或提供方会使相应调用不可用,且不会留下陈旧的活对象。 +每次注册都返回由调用方 Cordis fiber 持有的 disposer。挂载 Client contribution 时,descriptor 集与具体方法会作为一项有明确所有者的操作统一注册。Host Gateway 只缓存 SRC 所认领的 endpoint 名称集合,并在 Cordis Service 集合发生变化时整体丢弃该集合;它不保留 descriptor、Service 或提供方。调用时会从当前状态解析所有活对象,因此移除 strict definition、Service 或提供方会使相应调用不可用,且不会留下陈旧的活对象。 lookup 注册表会在活 resolver 卸载后保留稳定的 wire 声明。SRC 解析仍会把该参数归类为 lookup,而调用会以 `lookup-unavailable` 失败;系统绝不会把传入的 ID 重新归类为普通 JSON 业务对象。在同一个 TypeRT Service 的生命周期内,以不同参数、wire 或规范类型 symbol 重新注册同一 key 会直接失败。 @@ -355,7 +355,7 @@ CI 和发布运行 LIB。全仓 coverage 全部切换到 LIB 是独立后续工 ## Host Gateway 解析 -Host Gateway 向 Connection 注册一个 `/api` interceptor,不维护第二份 endpoint 注册表。ownership matcher 会从当前 TypeRT local 注册表解析各 endpoint,或扫描当前 Cordis Service,查找匹配的 `typertGateway` binding 与 SRC Remote 标记。因此 TypeRT definition 与业务 Service 可以按任意顺序到达。 +Host Gateway 向 Connection 注册一个 `/api` interceptor,不维护第二份 endpoint 注册表。ownership matcher 会先检查当前 TypeRT local 注册表,再查询一份可失效的集合;该集合通过扫描当前 Cordis Service 中的 `typertGateway` binding 与 SRC Remote 标记生成。Cordis Service 发生变化时会整体丢弃该集合,因此 TypeRT definition 与业务 Service 可以按任意顺序到达,同时既不会让旧 API Proxy 的 `/api` 流量在每次请求时重新扫描所有 Service,也不会因任意请求路径而扩大缓存。 每次调用都会重新从当前状态解析 descriptor、receiver、lookup 提供方与 Context 提供方。当前 strict descriptor 优先于 SRC。strict endpoint 一旦出现,即使随后撤回对应 descriptor,`TypeRTLocalRegistry.hasSeen()` 仍会在注册表剩余生命周期内保持对它的认领并禁止回退 SRC;重新注册 strict descriptor 即可恢复调用。移除 Service 或提供方会让调用明确失败;Gateway 既不保留失效对象,也不会以原始 lookup ID 调用方法。 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index f5b3a0b99a..34f4d37ffd 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -66,6 +66,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `credentials/changed` | `runtime` (`emit`) | `ui-models` | | `internal/dispatch` | - | [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `hmr`, `loader`, `modules`, `webserver` | +| `internal/service` | - | `api-gateway` | | `internal/status` | - | [`agent`](../packages/core/agent) | | `locale/change` | `locale` (`emit`) | `locale` | | `models/changed` | `runtime` (`emit`) | `ui-models` | diff --git a/packages/host/api-gateway/src/index.ts b/packages/host/api-gateway/src/index.ts index c4a61cef8d..7dd2410873 100644 --- a/packages/host/api-gateway/src/index.ts +++ b/packages/host/api-gateway/src/index.ts @@ -76,12 +76,17 @@ export class TypertGatewayError extends Error { export class TypertGatewayService extends Service implements TypertGateway { static inject = ['typert'] + private srcClaims: ReadonlySet | undefined + /** * Register the Gateway against the active TypeRT registry. * @param ctx - owning Host Context with TypeRT registry access. */ constructor(ctx: Context) { super(ctx, 'typertGateway') + ctx.on('internal/service', () => { + this.srcClaims = undefined + }) ctx.inject(['connection'], (connectionCtx) => { connectionCtx.connection.rpc.intercept( '/api', @@ -95,18 +100,26 @@ export class TypertGatewayService extends Service implements TypertGateway { private claimsEndpoint(endpoint: string): boolean { const segments = endpoint.split('/') if (segments.length !== 2 || segments[0] === '' || segments[1] === '') return false - const [namespace, method] = segments as [string, string] if (this.ctx.typert.local.get(endpoint) !== undefined || this.ctx.typert.local.hasSeen(endpoint)) return true + this.srcClaims ??= this.collectSrcClaims() + return this.srcClaims.has(endpoint) + } + + private collectSrcClaims(): ReadonlySet { + const claims = new Set() for (const [serviceKey, definition] of Object.entries(this.ctx.reflect.props)) { if (definition.type !== 'service') continue const receiver = this.ctx.get(serviceKey) as unknown if (!isObject(receiver)) continue const original = originalOf(receiver) const binding = Reflect.get(original, 'typertGateway') as unknown - if (!isObject(binding) || Reflect.get(binding, 'namespace') !== namespace) continue - if (remoteMethods(original).some(candidate => (candidate.exportName ?? candidate.method) === method)) return true + if (!isObject(binding) || typeof Reflect.get(binding, 'namespace') !== 'string') continue + const namespace = Reflect.get(binding, 'namespace') as string + for (const candidate of remoteMethods(original)) { + claims.add(endpointOf(namespace, candidate.exportName ?? candidate.method)) + } } - return false + return claims } /** diff --git a/packages/host/api-gateway/tests/gateway.spec.ts b/packages/host/api-gateway/tests/gateway.spec.ts index 6558a7ca47..aebe23da57 100644 --- a/packages/host/api-gateway/tests/gateway.spec.ts +++ b/packages/host/api-gateway/tests/gateway.spec.ts @@ -314,6 +314,25 @@ class NoBindingService extends Service { } } +class ObservedClaimService extends Service { + private readonly binding = bindTypeRTGateway(this, 'observedClaim', { namespace: 'observed-claim' }) + bindingReads = 0 + + constructor(ctx: Context) { + super(ctx, 'observedClaim') + } + + get typertGateway() { + this.bindingReads += 1 + return this.binding + } + + @Remote + run(value: string): string { + return value + } +} + class MissingMethodService extends Service { readonly typertGateway = bindTypeRTGateway(this, 'missingMethod', { namespace: 'missing-method' }) @@ -949,6 +968,36 @@ describe('TypertGatewayService', () => { expect(connection.handler).toBeUndefined() }) + it('caches SRC ownership until the Cordis Service set changes', async () => { + const ctx = new Context() + await ctx.plugin(TypertRegistry) + await ctx.plugin(FakeConnectionService) + await ctx.plugin(TypertGatewayService) + const observedFiber = ctx.plugin(ObservedClaimService) + await observedFiber + const connection = rawConnection(ctx) + const observed = ctx.get('observedClaim') as unknown as ObservedClaimService & { + [symbols.original]?: ObservedClaimService + } + const service = observed[symbols.original] ?? observed + + expect(connection.matches?.('legacy/list')).toBe(false) + expect(connection.matches?.('legacy/list')).toBe(false) + expect(service.bindingReads).toBe(1) + expect(connection.matches?.('observed-claim/run')).toBe(true) + expect(connection.matches?.('observed-claim/run')).toBe(true) + expect(service.bindingReads).toBe(1) + + const unrelatedFiber = ctx.plugin(NoBindingService) + await unrelatedFiber + expect(connection.matches?.('legacy/list')).toBe(false) + expect(service.bindingReads).toBe(2) + + await observedFiber.dispose() + expect(connection.matches?.('observed-claim/run')).toBe(false) + await unrelatedFiber.dispose() + }) + it('dispatches claimed invocations through /api and leaves unclaimed endpoints to its fallback', async () => { const ctx = new Context().extend({ fixtureScope: 'http-caller' }) const routes: WebRoute[] = [] From 286a8b168e1e4e536c75800d0b9d2523213f7991 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:53:16 +0800 Subject: [PATCH 080/104] fix(connection): route fixture calls through remote semantics --- .../client/connection/src/client/fixture.ts | 266 +++++++++++++----- .../client/connection/src/client/index.ts | 7 +- packages/client/connection/src/client/rpc.ts | 12 - .../connection/tests/client-apply.spec.ts | 32 ++- 4 files changed, 234 insertions(+), 83 deletions(-) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index e13c0a19f6..1a9841aece 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -36,6 +36,7 @@ import type { import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api' import { AbstractApiClient, RpcId, SESSION_SEARCH_RESULT_LIMIT } from './api.ts' import { randomUuid } from './random-uuid.ts' +import type { ClientConnectionRpc } from '../rpc.ts' /** The fake carrier mints like a real one (business code never mints). */ function rpcRequest

    (payload: P): RpcRequest

    { @@ -1329,6 +1330,16 @@ class FxInbox implements StreamConn { * @returns an ApiProxy backed entirely by in-memory state — no host process, no network. */ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { + return createFixtureWorld(options).api +} + +interface FixtureWorld { + readonly api: ApiProxy + readonly rpc: ClientConnectionRpc +} + +/** Build the fixture's legacy API and Remote RPC faces over one state graph. */ +function createFixtureWorld(options: FixtureOptions): FixtureWorld { // The resident fixture sessions all carry history, so none of them is blank. const sessions: SessionSummary[] = options.empty ? [] : [ { sessionId: sid('fx-alpha'), updatedAt: Date.now(), running: true, blank: false, cwd: '/tmp/fixture' }, @@ -1507,31 +1518,136 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { return backscanGoal(log) as FxGoalProjection } - /** Shared CAS mutation path of the goal verbs (undefined next = invalid transition). */ - const fxMutateGoal = ( - request: RpcRequest<{ sessionId: SessionId; ref: { id: string; revision: number } }>, - ref: { id: string; revision: number }, + type FxGoalRef = { id: string; revision: number } + type FxGoalView = FxGoalProjection['goal'] & { + roundsStarted: number + createdAt: number + updatedAt: number + activation: 'armed' | 'disarmed' + } + + const goalFailure = (message: string): RpcResult => ({ + ok: false, + error: { code: 'internal', message, details: {} }, + }) + + const requireGoalSession = (id: SessionId): RpcResult | undefined => ( + summaryOf(id) === undefined + ? { ok: false, error: { code: 'session-not-found', message: `no session ${id}`, details: { sessionId: id } } } + : undefined + ) + + const goalView = (projection: FxGoalProjection): FxGoalView => ({ + ...projection.goal, + roundsStarted: projection.roundsStarted, + createdAt: projection.createdAt, + updatedAt: projection.updatedAt, + activation: projection.goal.phase === 'active' ? 'armed' : 'disarmed', + }) + + /** Canonical fixture implementation of the generated Goal Remote contract. */ + const goalRemotes = { + create(id: SessionId, request: { objective: string; maxGoalRounds?: number }): RpcResult<{ ref: FxGoalRef }> { + const missing = requireGoalSession(id) + if (missing !== undefined) return missing + const current = backscanGoal(logOf(id)) + if (current !== null && current.goal.phase !== 'complete') { + return goalFailure(`goal "${current.goal.id}" already exists`) + } + const now = Date.now() + const projection = appendGoalChange(id, { + kind: 'goal/change', version: 1, operation: 'create', + goal: { + id: `fx-goal-${logOf(id).length}`, + revision: 1, + objective: request.objective, + phase: 'active', + maxGoalRounds: request.maxGoalRounds ?? 256, + }, + roundsStarted: 0, createdAt: now, updatedAt: now, + }) + return { ok: true, value: { ref: { id: projection.goal.id, revision: projection.goal.revision } } } + }, + edit(id: SessionId, ref: FxGoalRef, request: { objective?: string; maxGoalRounds?: number }): RpcResult { + return mutateGoal(id, ref, current => ({ + ...current.goal, + revision: current.goal.revision + 1, + ...request.objective === undefined ? {} : { objective: request.objective }, + ...request.maxGoalRounds === undefined ? {} : { maxGoalRounds: request.maxGoalRounds }, + })) + }, + pause(id: SessionId, ref: FxGoalRef): RpcResult { + return mutateGoal(id, ref, current => ( + current.goal.phase === 'active' + ? { ...current.goal, revision: current.goal.revision + 1, phase: 'paused' } + : undefined + )) + }, + resume(id: SessionId, ref: FxGoalRef): RpcResult { + return mutateGoal(id, ref, current => ( + current.goal.phase === 'paused' || current.goal.phase === 'blocked' || current.goal.phase === 'active' + ? { ...current.goal, revision: current.goal.revision + 1, phase: 'active' } + : undefined + )) + }, + complete(id: SessionId, ref: FxGoalRef): RpcResult { + return mutateGoal(id, ref, current => ( + current.goal.phase === 'complete' + ? undefined + : { ...current.goal, revision: current.goal.revision + 1, phase: 'complete' } + )) + }, + clear(id: SessionId, ref: FxGoalRef): RpcResult { + const missing = requireGoalSession(id) + if (missing !== undefined) return missing + const current = backscanGoal(logOf(id)) + if (current === null || current.goal.id !== ref.id || current.goal.revision !== ref.revision) { + return goalFailure('stale or missing goal revision') + } + const tombstone = { id: current.goal.id, revision: current.goal.revision + 1 } + appendGoalChange(id, { + kind: 'goal/change', version: 1, operation: 'clear', cleared: tombstone, clearedAt: Date.now(), + }) + return { ok: true, value: tombstone } + }, + } + + /** Shared CAS mutation path behind the canonical Remote verbs. */ + function mutateGoal( + id: SessionId, + ref: FxGoalRef, next: (current: FxGoalProjection) => FxGoalProjection['goal'] | undefined, - ): Promise> => { - const missing = requireSession(request) + ): RpcResult { + const missing = requireGoalSession(id) if (missing !== undefined) return missing - const id = request.payload.sessionId const current = backscanGoal(logOf(id)) if (current === null || current.goal.id !== ref.id || current.goal.revision !== ref.revision) { - return err(request, { code: 'internal', message: 'stale or missing goal revision', details: { goalCode: 'GOAL_STALE_REVISION' } }) + return goalFailure('stale or missing goal revision') } const goal = next(current) if (goal === undefined) { - return err(request, { code: 'internal', message: `invalid goal transition from "${current.goal.phase}"`, details: { goalCode: 'GOAL_INVALID_TRANSITION' } }) + return goalFailure(`invalid goal transition from "${current.goal.phase}"`) } const projection = appendGoalChange(id, { kind: 'goal/change', version: 1, operation: goal.phase === current.goal.phase ? 'edit' : goal.phase === 'paused' ? 'pause' : goal.phase === 'active' ? 'resume' : 'complete', goal, roundsStarted: current.roundsStarted, createdAt: current.createdAt, updatedAt: Date.now(), }) - return ok(request, { ref: { id: projection.goal.id as never, revision: projection.goal.revision } }) + return { ok: true, value: goalView(projection) } } + const mapGoalResult = (result: RpcResult, map: (value: T) => U): RpcResult => ( + result.ok ? { ok: true, value: map(result.value) } : result + ) + + const goalRefResult = (result: RpcResult): RpcResult<{ ref: { id: never; revision: number } }> => ( + mapGoalResult(result, view => ({ ref: { id: view.id as never, revision: view.revision } })) + ) + + const legacyGoalResponse = (request: RpcRequest

    , result: RpcResult): Promise> => ( + Promise.resolve({ rpcId: request.rpcId, result }) + ) + /** At most one in-flight replay per session; cancel clears it. */ const replays = new Map; finish(aborted: boolean): void }>() @@ -1777,7 +1893,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { replays.set(id, { timer: setTimeout(tick, 80), finish }) } - return { + const api: ApiProxy = { sessions: { list: request => ok(request, { items: [...sessions].sort((a, b) => b.updatedAt - a.updatedAt) }), search: (request, signal) => { @@ -2334,60 +2450,44 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { }, }, goals: { - // Mutation-only mirror of the host handlers: each verb CAS-checks the - // projected current goal, appends the whole-value change (the mux - // stream and projection frame ride the shared append path), and - // acknowledges with the new ref only. - create: (request) => { - const missing = requireSession(request) - if (missing !== undefined) return missing - const id = request.payload.sessionId - const current = backscanGoal(logOf(id)) - if (current !== null && current.goal.phase !== 'complete') { - return err(request, { code: 'internal', message: `goal "${current.goal.id}" already exists`, details: { goalCode: 'GOAL_ALREADY_EXISTS' } }) - } - const projection = appendGoalChange(id, { - kind: 'goal/change', version: 1, operation: 'create', - goal: { id: `fx-goal-${logOf(id).length}`, revision: 1, objective: request.payload.objective, phase: 'active', maxGoalRounds: request.payload.maxGoalRounds ?? 256 }, - roundsStarted: 0, createdAt: Date.now(), updatedAt: Date.now(), - }) - return ok(request, { ref: { id: projection.goal.id as never, revision: projection.goal.revision } }) - }, - edit: request => fxMutateGoal(request, request.payload.ref, current => ({ - ...current.goal, - revision: current.goal.revision + 1, - ...request.payload.objective === undefined ? {} : { objective: request.payload.objective }, - ...request.payload.maxGoalRounds === undefined ? {} : { maxGoalRounds: request.payload.maxGoalRounds }, - })), - pause: request => fxMutateGoal(request, request.payload.ref, current => ( - current.goal.phase === 'active' - ? { ...current.goal, revision: current.goal.revision + 1, phase: 'paused' } - : undefined - )), - resume: request => fxMutateGoal(request, request.payload.ref, current => ( - current.goal.phase === 'paused' || current.goal.phase === 'blocked' || current.goal.phase === 'active' - ? { ...current.goal, revision: current.goal.revision + 1, phase: 'active' } - : undefined - )), - complete: request => fxMutateGoal(request, request.payload.ref, current => ( - current.goal.phase === 'complete' - ? undefined - : { ...current.goal, revision: current.goal.revision + 1, phase: 'complete' } - )), - clear: (request) => { - const missing = requireSession(request) - if (missing !== undefined) return missing - const id = request.payload.sessionId - const current = backscanGoal(logOf(id)) - if (current === null || current.goal.id !== request.payload.ref.id || current.goal.revision !== request.payload.ref.revision) { - return err(request, { code: 'internal', message: 'stale or missing goal revision', details: { goalCode: 'GOAL_STALE_REVISION' } }) - } - appendGoalChange(id, { - kind: 'goal/change', version: 1, operation: 'clear', - cleared: { id: current.goal.id, revision: current.goal.revision + 1 }, clearedAt: Date.now(), - }) - return ok(request, { cleared: true as const }) - }, + // Compatibility face only: old API Proxy payloads and acknowledgements + // adapt to the canonical fixture Remote implementation above. + create: request => legacyGoalResponse( + request, + mapGoalResult( + goalRemotes.create(request.payload.sessionId, { + objective: request.payload.objective, + ...request.payload.maxGoalRounds === undefined ? {} : { maxGoalRounds: request.payload.maxGoalRounds }, + }), + value => ({ ref: { id: value.ref.id as never, revision: value.ref.revision } }), + ), + ), + edit: request => legacyGoalResponse( + request, + goalRefResult(goalRemotes.edit(request.payload.sessionId, request.payload.ref, { + ...request.payload.objective === undefined ? {} : { objective: request.payload.objective }, + ...request.payload.maxGoalRounds === undefined ? {} : { maxGoalRounds: request.payload.maxGoalRounds }, + })), + ), + pause: request => legacyGoalResponse( + request, + goalRefResult(goalRemotes.pause(request.payload.sessionId, request.payload.ref)), + ), + resume: request => legacyGoalResponse( + request, + goalRefResult(goalRemotes.resume(request.payload.sessionId, request.payload.ref)), + ), + complete: request => legacyGoalResponse( + request, + goalRefResult(goalRemotes.complete(request.payload.sessionId, request.payload.ref)), + ), + clear: request => legacyGoalResponse( + request, + mapGoalResult( + goalRemotes.clear(request.payload.sessionId, request.payload.ref), + () => ({ cleared: true as const }), + ), + ), }, events: { async *mux(_request, signal) { @@ -2548,6 +2648,36 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { return Promise.resolve({ accepted: true }) }, } + + const rpc: ClientConnectionRpc = { + call(channel, endpoint, payload) { + if (channel !== '/api') { + return Promise.reject(new Error(`fixture connection RPC channel ${JSON.stringify(channel)} is unavailable`)) + } + const args = (payload as { + args: { + agentId: SessionId + ref?: { id: string; revision: number } + request?: { objective?: string; maxGoalRounds?: number } + } + }).args + const sessionId = args.agentId + switch (endpoint) { + case 'goals/create': return Promise.resolve(goalRemotes.create(sessionId, { + objective: args.request?.objective as string, + ...args.request?.maxGoalRounds === undefined ? {} : { maxGoalRounds: args.request.maxGoalRounds }, + })) + case 'goals/edit': return Promise.resolve(goalRemotes.edit(sessionId, args.ref as FxGoalRef, args.request ?? {})) + case 'goals/pause': return Promise.resolve(goalRemotes.pause(sessionId, args.ref as FxGoalRef)) + case 'goals/resume': return Promise.resolve(goalRemotes.resume(sessionId, args.ref as FxGoalRef)) + case 'goals/complete': return Promise.resolve(goalRemotes.complete(sessionId, args.ref as FxGoalRef)) + case 'goals/clear': return Promise.resolve(goalRemotes.clear(sessionId, args.ref as FxGoalRef)) + default: + return Promise.reject(new Error(`fixture connection RPC endpoint ${JSON.stringify(endpoint)} is unavailable`)) + } + }, + } + return { api, rpc } } /** @@ -2559,10 +2689,14 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { */ 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() - this.api = createFixtureApi(fixtureOptionsFromLocation()) + const world = createFixtureWorld(fixtureOptionsFromLocation()) + this.api = world.api + this.rpc = world.rpc } protected doFetch(): Promise { diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 521e54160e..c2a6668d46 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -8,7 +8,7 @@ import type { IApiClient } from './api.ts' import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts' import { FixtureApiClient } from './fixture.ts' import { WebApiClient } from './web-api-client.ts' -import { createUnavailableConnectionRpc, createWebConnectionRpc } from './rpc.ts' +import { createWebConnectionRpc } from './rpc.ts' import { isLoopbackHostname } from '../loopback-hostname.ts' import type { ClientConnectionRpc } from '../rpc.ts' @@ -74,8 +74,9 @@ export interface ConnectionHandle { export function apply(ctx: Context): void { const pageLocation = typeof location === 'undefined' ? undefined : location const fixture = pageLocation !== undefined && new URLSearchParams(pageLocation.search).has('fixture') - const api: IApiClient = fixture ? new FixtureApiClient() : new WebApiClient() - const rpc = fixture ? createUnavailableConnectionRpc() : createWebConnectionRpc() + const fixtureClient = fixture ? new FixtureApiClient() : undefined + const api: IApiClient = fixtureClient ?? new WebApiClient() + const rpc = fixtureClient?.rpc ?? createWebConnectionRpc() let started = false const handle: ConnectionHandle = { api, diff --git a/packages/client/connection/src/client/rpc.ts b/packages/client/connection/src/client/rpc.ts index 7883f2a9d3..f8bacb1553 100644 --- a/packages/client/connection/src/client/rpc.ts +++ b/packages/client/connection/src/client/rpc.ts @@ -48,18 +48,6 @@ export function createWebConnectionRpc(): ClientConnectionRpc { } } -/** - * Create the fixture-mode caller, where no Host Remote registry exists. - * @returns caller that rejects every generic Remote invocation. - */ -export function createUnavailableConnectionRpc(): ClientConnectionRpc { - return { - call(channel, endpoint) { - return Promise.reject(new Error(`connection RPC ${channel}/${endpoint} is unavailable in fixture mode`)) - }, - } -} - function resolveBase(): string { const location = (globalThis as { location?: { origin?: string } }).location return location?.origin !== undefined && location.origin !== 'null' ? location.origin : INTERNAL_BASE diff --git a/packages/client/connection/tests/client-apply.spec.ts b/packages/client/connection/tests/client-apply.spec.ts index 41e8e9b0e2..9d9bbd2f26 100644 --- a/packages/client/connection/tests/client-apply.spec.ts +++ b/packages/client/connection/tests/client-apply.spec.ts @@ -285,9 +285,37 @@ describe('connection client apply', () => { } }) - it('keeps generic Remote calls unavailable in the client-only fixture', async () => { + it('carries Goal Remotes over the same state as the client-only fixture API', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } const handle = await mount() - await expect(handle.rpc.call('/api', 'goals/create', {})).rejects.toThrow(/unavailable in fixture mode/) + const created = await handle.rpc.call('/api', 'goals/create', { + args: { agentId: 'fx-alpha', request: { objective: 'fixture remote' } }, + }) + expect(created).toMatchObject({ ok: true, value: { ref: { revision: 1 } } }) + if (!created.ok) throw new Error('fixture Goal create failed') + const ref = (created.value as { ref: { id: string; revision: number } }).ref + const edited = await handle.rpc.call('/api', 'goals/edit', { + args: { agentId: 'fx-alpha', ref, request: { objective: 'edited fixture remote' } }, + }) + expect(edited).toMatchObject({ ok: true, value: { objective: 'edited fixture remote', revision: 2 } }) + const editedRef = { id: ref.id, revision: 2 } + const paused = await handle.rpc.call('/api', 'goals/pause', { + args: { agentId: 'fx-alpha', ref: editedRef }, + }) + expect(paused).toMatchObject({ ok: true, value: { phase: 'paused', activation: 'disarmed', revision: 3 } }) + const resumed = await handle.rpc.call('/api', 'goals/resume', { + args: { agentId: 'fx-alpha', ref: { id: ref.id, revision: 3 } }, + }) + expect(resumed).toMatchObject({ ok: true, value: { phase: 'active', activation: 'armed', revision: 4 } }) + const completed = await handle.rpc.call('/api', 'goals/complete', { + args: { agentId: 'fx-alpha', ref: { id: ref.id, revision: 4 } }, + }) + expect(completed).toMatchObject({ ok: true, value: { phase: 'complete', activation: 'disarmed', revision: 5 } }) + await expect(handle.rpc.call('/api', 'goals/clear', { + args: { agentId: 'fx-alpha', ref: { id: ref.id, revision: 5 } }, + })).resolves.toEqual({ ok: true, value: { id: ref.id, revision: 6 } }) + await expect(handle.rpc.call('/other', 'goals/create', {})).rejects.toThrow(/channel.*unavailable/) + await expect(handle.rpc.call('/api', 'unknown/read', { args: { agentId: 'fx-alpha' } })) + .rejects.toThrow(/endpoint.*unavailable/) }) }) From 737c12935ac1c95bd4118422c19f283abdb540f6 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:04:42 +0800 Subject: [PATCH 081/104] fix(connection): share fixture goal revision lookup --- .../client/connection/src/client/fixture.ts | 29 +++++++++++-------- .../request-response.expected.json | 4 +-- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 1a9841aece..776d21fd46 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1598,12 +1598,9 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { )) }, clear(id: SessionId, ref: FxGoalRef): RpcResult { - const missing = requireGoalSession(id) - if (missing !== undefined) return missing - const current = backscanGoal(logOf(id)) - if (current === null || current.goal.id !== ref.id || current.goal.revision !== ref.revision) { - return goalFailure('stale or missing goal revision') - } + const resolved = resolveGoal(id, ref) + if (!resolved.ok) return resolved + const current = resolved.value const tombstone = { id: current.goal.id, revision: current.goal.revision + 1 } appendGoalChange(id, { kind: 'goal/change', version: 1, operation: 'clear', cleared: tombstone, clearedAt: Date.now(), @@ -1612,18 +1609,26 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { }, } - /** Shared CAS mutation path behind the canonical Remote verbs. */ - function mutateGoal( - id: SessionId, - ref: FxGoalRef, - next: (current: FxGoalProjection) => FxGoalProjection['goal'] | undefined, - ): RpcResult { + /** Resolve one current goal revision for a canonical Remote mutation. */ + function resolveGoal(id: SessionId, ref: FxGoalRef): RpcResult { const missing = requireGoalSession(id) if (missing !== undefined) return missing const current = backscanGoal(logOf(id)) if (current === null || current.goal.id !== ref.id || current.goal.revision !== ref.revision) { return goalFailure('stale or missing goal revision') } + return { ok: true, value: current } + } + + /** Shared CAS mutation path behind the canonical Remote verbs. */ + function mutateGoal( + id: SessionId, + ref: FxGoalRef, + next: (current: FxGoalProjection) => FxGoalProjection['goal'] | undefined, + ): RpcResult { + const resolved = resolveGoal(id, ref) + if (!resolved.ok) return resolved + const current = resolved.value const goal = next(current) if (goal === undefined) { return goalFailure(`invalid goal transition from "${current.goal.phase}"`) diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index b9d67f4bf6..e796de8a8a 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -16,11 +16,11 @@ }, { "role": "user", - "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThe setup tutorial takes a new contributor from prerequisites to a checked checkout. The contributor reference that follows covers repository layout, daily workflow, and CI shape. Design rationale and implementation details belong to the linked Agent Notes and scripts.\n\n## Setup tutorial\n\n### Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the Web, headless, and ACP automation demos and real-API e2e tests.\n\n### First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also configures worktree-local lefthook hooks through `scripts/install-lefthook.mjs`. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the safety and migration contract.\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nIf the wrapper rejects existing Git configuration or reports a stale lock, follow its diagnostic and the linked Agent Note rather than editing worktree metadata speculatively. After moving a checkout, rerun the wrapper to regenerate the owned path.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nSetup is complete when `pnpm run typecheck` exits successfully.\n\n## Contributor reference\n\n### TypeScript project layout\n\nThe repository typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through two no-emit aggregates.\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- 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. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md).\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n### Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n### Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` applies formatting-only ESLint fixes, validates the staged files with Oxlint and applies its native fixes, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n### CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n### Daily commands\n\nThe root [contributor instructions](../AGENTS.md#commands) summarize common commands, while [`package.json`](../package.json) and [scripts/run-gates.ts](../scripts/run-gates.ts) own the current script and gate inventories. Select the smallest checks that cover the changed surface. Documentation changes use `pnpm run doc-sync`; package-public behavior changes also update the owning README or JSDoc, and built-artifact checks require `pnpm run build` first.\n\n### Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n### Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n" + "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThe setup tutorial takes a new contributor from prerequisites to a checked checkout. The contributor reference that follows covers repository layout, daily workflow, and CI shape. Design rationale and implementation details belong to the linked Agent Notes and scripts.\n\n## Setup tutorial\n\n### Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the Web, headless, and ACP automation demos and real-API e2e tests.\n\n### First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also configures worktree-local lefthook hooks through `scripts/install-lefthook.mjs`. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the safety and migration contract.\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nIf the wrapper rejects existing Git configuration or reports a stale lock, follow its diagnostic and the linked Agent Note rather than editing worktree metadata speculatively. After moving a checkout, rerun the wrapper to regenerate the owned path.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nSetup is complete when `pnpm run typecheck` exits successfully.\n\n## Contributor reference\n\n### TypeScript project layout\n\nThe repository typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through two no-emit aggregates.\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- 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. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md).\n\nBusiness services declare callable methods on the Host with `@Remote` or `@RemoteContext`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `client-remotes` composition loads those contributions and mounts concrete methods on `ctx.api` or the corresponding scoped Context. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order.\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n### Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n### Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` applies formatting-only ESLint fixes, validates the staged files with Oxlint and applies its native fixes, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n### CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n### Daily commands\n\nThe root [contributor instructions](../AGENTS.md#commands) summarize common commands, while [`package.json`](../package.json) and [scripts/run-gates.ts](../scripts/run-gates.ts) own the current script and gate inventories. Select the smallest checks that cover the changed surface. Documentation changes use `pnpm run doc-sync`; package-public behavior changes also update the owning README or JSDoc, and built-artifact checks require `pnpm run build` first.\n\n### Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n### Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n" }, { "role": "assistant", - "content": "# 开发指南\n\n[English](development.md) | 中文\n\n搭建教程引导新贡献者从准备前置条件开始,直到检出通过检查。后面的贡献者参考介绍仓库布局、日常工作流和 CI 形态。设计依据与实现细节属于链接的 Agent Note 和脚本。\n\n## 搭建教程\n\n### 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 Web、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n### 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程还会通过 `scripts/install-lefthook.mjs` 配置 worktree 本地的 lefthook 钩子。其安全与迁移契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 负责。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n如果包装脚本拒绝现有 Git 配置或报告陈旧锁,请遵循其诊断和所链接的 Agent Note,不要凭猜测编辑 worktree 元数据。移动检出目录后,请重新运行包装脚本以重新生成自有路径。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n`pnpm run typecheck` 成功退出即表示搭建完成。\n\n## 贡献者参考\n\n### TypeScript 项目布局\n\n仓库类型检查会执行全仓 `tsc -b` 图:它会发射每个 package/vendor 的 `lib/types`,并通过两个 no-emit 聚合检查示例、测试和脚本。\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n### 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n### Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 应用仅用于格式化的 ESLint 修复,使用 Oxlint 验证暂存文件并应用其原生修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n### CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n### 日常命令\n\n根目录的[贡献者说明](../AGENTS.md#commands)概述常用命令,[`package.json`](../package.json) 与 [scripts/run-gates.ts](../scripts/run-gates.ts) 则负责当前脚本和门禁清单。请选择覆盖变更表面的最小检查集。文档变更使用 `pnpm run doc-sync`;package 公开行为变更还需更新所属 README 或 JSDoc,而基于构建产物的检查需要先运行 `pnpm run build`。\n\n### 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n### 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n" + "content": "# 开发指南\n\n[English](development.md) | 中文\n\n搭建教程引导新贡献者从准备前置条件开始,直到检出通过检查。后面的贡献者参考介绍仓库布局、日常工作流和 CI 形态。设计依据与实现细节属于链接的 Agent Note 和脚本。\n\n## 搭建教程\n\n### 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 Web、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n### 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程还会通过 `scripts/install-lefthook.mjs` 配置 worktree 本地的 lefthook 钩子。其安全与迁移契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 负责。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n如果包装脚本拒绝现有 Git 配置或报告陈旧锁,请遵循其诊断和所链接的 Agent Note,不要凭猜测编辑 worktree 元数据。移动检出目录后,请重新运行包装脚本以重新生成自有路径。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n`pnpm run typecheck` 成功退出即表示搭建完成。\n\n## 贡献者参考\n\n### TypeScript 项目布局\n\n仓库类型检查会执行全仓 `tsc -b` 图:它会发射每个 package/vendor 的 `lib/types`,并通过两个 no-emit 聚合检查示例、测试和脚本。\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。\n\n业务 Service 在 Host 使用 `@Remote` 或 `@RemoteContext` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `client-remotes` 组合加载这些贡献并将具体方法挂载到 `ctx.api` 或对应的作用域 Context。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n### 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n### Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 应用仅用于格式化的 ESLint 修复,使用 Oxlint 验证暂存文件并应用其原生修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n### CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n### 日常命令\n\n根目录的[贡献者说明](../AGENTS.md#commands)概述常用命令,[`package.json`](../package.json) 与 [scripts/run-gates.ts](../scripts/run-gates.ts) 则负责当前脚本和门禁清单。请选择覆盖变更表面的最小检查集。文档变更使用 `pnpm run doc-sync`;package 公开行为变更还需更新所属 README 或 JSDoc,而基于构建产物的检查需要先运行 `pnpm run build`。\n\n### 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n### 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n" }, { "role": "user", From 2fe4a53557179de0fbebe4e83e8cb18e735f112b Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:07:19 +0800 Subject: [PATCH 082/104] fix(typert): validate and mount remote contributions safely --- packages/host/api-gateway/src/client/index.ts | 72 ++++++++++++----- .../host/api-gateway/tests/client.spec.ts | 80 +++++++++++++++++++ packages/typert/generator/src/analyzer.ts | 4 +- packages/typert/generator/src/workspace.ts | 1 + .../generator/tests/remote-model.spec.ts | 33 +++++++- packages/typert/registry/src/service.ts | 2 +- packages/typert/registry/tests/typert.spec.ts | 2 +- packages/typert/type-meta/src/index.ts | 2 +- .../typert/type-meta/tests/type-meta.spec.ts | 2 + 9 files changed, 173 insertions(+), 25 deletions(-) diff --git a/packages/host/api-gateway/src/client/index.ts b/packages/host/api-gateway/src/client/index.ts index 3fc8389079..5503fc3dcf 100644 --- a/packages/host/api-gateway/src/client/index.ts +++ b/packages/host/api-gateway/src/client/index.ts @@ -4,7 +4,7 @@ * lookup, invocation, or type exposure. */ -import { Service } from 'cordis' +import { Service, symbols } from 'cordis' import type { Context } from 'cordis' import type { ConnectionHandle, RpcError } from '@deepseek-ai/dsh-client-connection/client' import type { @@ -84,7 +84,13 @@ class ClientApiService extends Service implements ClientApi { let disposeMethods: () => void | Promise try { disposeMethods = callerCtx.effect(() => { - const installed = contribution.descriptors.map(descriptor => this.install(descriptor)) + const installed: Array<() => void> = [] + try { + for (const descriptor of contribution.descriptors) installed.push(this.install(descriptor)) + } catch (error) { + for (const dispose of installed.reverse()) dispose() + throw error + } return () => { for (const dispose of installed.reverse()) dispose() } @@ -169,21 +175,27 @@ class ClientApiService extends Service implements ClientApi { private installDirect(descriptor: InvocationDescriptor, token: MountToken): () => void { let namespace = this.direct.get(descriptor.namespace) + const fresh = namespace === undefined if (namespace === undefined) { namespace = { value: Object.create(null) as Record, tokens: new Map() } - this.direct.set(descriptor.namespace, namespace) Object.defineProperty(this, descriptor.namespace, { configurable: true, enumerable: true, value: namespace.value, }) } + try { + Object.defineProperty(namespace.value, descriptor.method, { + configurable: true, + enumerable: true, + value: (...args: unknown[]) => this.invoke(descriptor, undefined, token, this.ownerCtx, args), + }) + } catch (error) { + if (fresh) Reflect.deleteProperty(this, descriptor.namespace) + throw error + } + if (fresh) this.direct.set(descriptor.namespace, namespace) namespace.tokens.set(descriptor.method, token) - Object.defineProperty(namespace.value, descriptor.method, { - configurable: true, - enumerable: true, - value: (...args: unknown[]) => this.invoke(descriptor, undefined, token, this.ownerCtx, args), - }) return () => { /* v8 ignore next -- duplicate live methods are rejected before installation, so no newer token can replace this one. */ if (namespace.tokens.get(descriptor.method) !== token) return @@ -242,7 +254,7 @@ class ClientApiService extends Service implements ClientApi { `client api: ${endpoint} expected ${contract}, got ${String(values.length)}`, ) } - const args: Record = {} + const args = Object.create(null) as Record if (projection !== undefined) { const binder = this.ownerCtx.typert.contexts.getClient(projection.context) if (binder === undefined) { @@ -281,9 +293,12 @@ type InvokeRemote = ( args: readonly unknown[], ) => Promise -class ScopedRemoteNamespace extends Service { +class ScopedRemoteNamespace { + private readonly ctx: Context private readonly ownerCtx: Context private readonly methods = new Set() + private provided = false + readonly name: string static assertMethodAvailable(namespace: string, method: string): void { if (SCOPED_NAMESPACE_FIELDS.has(method) || method in ScopedRemoteNamespace.prototype) { @@ -296,8 +311,12 @@ class ScopedRemoteNamespace extends Service { name: string, private readonly invokeRemote: InvokeRemote, ) { - super(ctx, name) + this.ctx = ctx this.ownerCtx = ctx + this.name = name + Object.defineProperty(this, symbols.tracker, { + value: { associate: name, property: 'ctx' }, + }) } assertMethodAvailable(method: string): void { @@ -309,15 +328,28 @@ class ScopedRemoteNamespace extends Service { install(descriptor: InvocationDescriptor, projection: ScopedProjection, token: MountToken): void { this.assertMethodAvailable(descriptor.method) - if (this.methods.size === 0) this.ownerCtx.set(this.name, this) + const activate = this.methods.size === 0 const method = descriptor.method - Object.defineProperty(this, method, { - configurable: true, - enumerable: true, - value: function (this: ScopedRemoteNamespace, ...args: unknown[]): Promise { - return this.invokeRemote(descriptor, projection, token, this.ctx, args) - }, - }) + try { + Object.defineProperty(this, method, { + configurable: true, + enumerable: true, + value: function (this: ScopedRemoteNamespace, ...args: unknown[]): Promise { + return this.invokeRemote(descriptor, projection, token, this.ctx, args) + }, + }) + if (activate) { + if (this.provided) { + this.ownerCtx.set(this.name, this) + } else { + this.ownerCtx.reflect.provide(this.name, this) + this.provided = true + } + } + } catch (error) { + Reflect.deleteProperty(this, method) + throw error + } this.methods.add(method) } @@ -328,7 +360,7 @@ class ScopedRemoteNamespace extends Service { } } -const SCOPED_NAMESPACE_FIELDS = new Set(['ctx', 'invokeRemote', 'methods', 'name', 'ownerCtx']) +const SCOPED_NAMESPACE_FIELDS = new Set(['ctx', 'invokeRemote', 'methods', 'name', 'ownerCtx', 'provided']) function endpointOf(descriptor: Pick): string { return `${descriptor.namespace}/${descriptor.method}` diff --git a/packages/host/api-gateway/tests/client.spec.ts b/packages/host/api-gateway/tests/client.spec.ts index 5c2c427605..c1f94f2e44 100644 --- a/packages/host/api-gateway/tests/client.spec.ts +++ b/packages/host/api-gateway/tests/client.spec.ts @@ -321,6 +321,34 @@ describe('Client TypeRT API', () => { await disposeScoped() }) + it('rolls back earlier descriptors when a later descriptor fails to install', async () => { + const ctx = await bench(vi.fn()) + const { scope: _scope, ...first } = directDescriptor() + const second: InvocationDescriptor = { + ...first, + id: '@fixture/goals#goals/archive', + method: 'archive', + } + const defineProperty = Object.defineProperty + const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => { + if (key === 'archive') throw new Error('fixture later-descriptor failure') + return defineProperty(target, key, attributes) + }) + try { + expect(() => ctx.api.mount({ package: '@fixture/failing-batch', descriptors: [first, second] })) + .toThrow('fixture later-descriptor failure') + } finally { + spy.mockRestore() + } + + expect((ctx.api as unknown as Record).goals).toBeUndefined() + await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) + const retry = ctx.api.mount({ package: '@fixture/retry-batch', descriptors: [first, second] }) + expect(ctx.api.goals.create).toBeTypeOf('function') + expect((ctx.api.goals as unknown as Record).archive).toBeTypeOf('function') + await retry() + }) + it('rejects weak parameter and Context codecs plus malformed scope projections', async () => { const ctx = await bench(vi.fn()) const direct = directDescriptor() @@ -409,6 +437,33 @@ describe('Client TypeRT API', () => { expect((ctx.api as unknown as Record).goals).toBeUndefined() }) + it('preserves a __proto__ wire parameter as an own named argument', async () => { + const call = vi.fn() + .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } }) + const ctx = await bench(call) + const { scope: _scope, ...base } = directDescriptor() + const descriptor: InvocationDescriptor = { + ...base, + id: '@fixture/goals#goals/prototype', + method: 'prototype', + parameters: [{ + name: 'value', + wire: '__proto__', + source: 'json', + codec: { mode: 'strict', typeSymbol: '@fixture#PrototypeValue', schema: z.string() }, + }], + } + const dispose = ctx.api.mount({ package: '@fixture/prototype', descriptors: [descriptor] }) + + const method = (ctx.api.goals as unknown as Record Promise>).prototype + await expect(method?.('wire-value')).resolves.toEqual({ ref: 'goal-1' }) + const payload = call.mock.calls[0]?.[2] as { readonly args: Record } + expect(Object.getPrototypeOf(payload.args)).toBeNull() + expect(Object.hasOwn(payload.args, '__proto__')).toBe(true) + expect(payload.args.__proto__).toBe('wire-value') + await dispose() + }) + it('rolls back Remote registration when concrete method installation fails', async () => { const ctx = await bench(vi.fn()) const defineProperty = Object.defineProperty @@ -423,6 +478,31 @@ describe('Client TypeRT API', () => { } finally { spy.mockRestore() } + + const retry = ctx.api.mount({ package: '@fixture/goals-retry', descriptors: [directDescriptor()] }) + expect(ctx.api.goals.create).toBeTypeOf('function') + await retry() + }) + + it('withdraws a fresh scoped Service when its first method fails to install', async () => { + const ctx = await bench(vi.fn()) + const defineProperty = Object.defineProperty + const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => { + if (key === 'rename') throw new Error('fixture scoped installation failure') + return defineProperty(target, key, attributes) + }) + try { + expect(() => ctx.api.mount({ package: '@fixture/scoped-failure', descriptors: [contextDescriptor()] })) + .toThrow('fixture scoped installation failure') + } finally { + spy.mockRestore() + } + + expect(ctx.get('goals')).toBeUndefined() + await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) + const retry = ctx.api.mount({ package: '@fixture/scoped-retry', descriptors: [contextDescriptor()] }) + expect((ctx.get('goals') as unknown as Record).rename).toBeTypeOf('function') + await retry() }) it('throws RPC failures with the structured error as its cause', async () => { diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index 16c30e8bc5..bc5024a7d8 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -2810,7 +2810,9 @@ function stringLiteralValue(node: ts.Node | undefined): string | undefined { } function isRemoteSegment(value: string): boolean { - return /^[A-Za-z0-9_$.-]+$/.test(value) + // Generation bootstraps workspace artifacts before dsh-type-meta is built, + // so this extraction-only copy must mirror isTypeRTRemoteSegment(). + return value !== '.' && value !== '..' && /^[A-Za-z0-9_$.-]+$/.test(value) } function expressionName(node: ts.Expression): string | undefined { diff --git a/packages/typert/generator/src/workspace.ts b/packages/typert/generator/src/workspace.ts index 6327872166..4a303c4bd4 100644 --- a/packages/typert/generator/src/workspace.ts +++ b/packages/typert/generator/src/workspace.ts @@ -90,6 +90,7 @@ export class WorkspaceTypertGenerator { throw new TypertAnalysisError(`typert(${artifact.face}): ${artifact.package} package files must include ${file}`) } } + if (artifact.face !== 'host') return const remoteExpected = { types: './lib/typert.remote-client.d.ts', default: './lib/typert.remote-client.js', diff --git a/packages/typert/generator/tests/remote-model.spec.ts b/packages/typert/generator/tests/remote-model.spec.ts index 4f4f3ea7cb..27bdac2fac 100644 --- a/packages/typert/generator/tests/remote-model.spec.ts +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -274,7 +274,7 @@ export interface BoxPayload { assertRemoteConsumerTypechecks(artifact?.remote?.dts, artifact?.remote?.dtsMap, root) }) - it.each(['create#v2', 'create goal'])('rejects untransportable Remote alias %s', (alias) => { + it.each(['create#v2', 'create goal', '.', '..'])('rejects untransportable Remote alias %s', (alias) => { const root = copyFixture() editFile(root, 'packages/remote/src/index.ts', source => source.replace( ' @Remote\n async create(', @@ -301,6 +301,37 @@ export interface RemainingSchema { .toThrow('publishes Remote artifacts but has no Remote methods') }) + it('validates Remote artifacts only on the host face of a dual-face package', () => { + const root = copyFixture() + const manifestPath = join(root, 'packages/remote/package.json') + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { + dshClient?: object + exports: Record + files: string[] + } + manifest.dshClient = {} + manifest.exports['./client'] = './src/client.ts' + manifest.exports['./client/typert'] = { + types: './lib/typert.client.d.ts', + default: './lib/typert.client.js', + } + manifest.files.push('lib/typert.client.js', 'lib/typert.client.d.ts') + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`) + writeFileSync(join(root, 'tsconfig.client.json'), `${JSON.stringify({ + extends: './tsconfig.base.json', + files: [], + references: [{ path: './packages/remote' }], + }, null, 2)}\n`) + writeFileSync(join(root, 'packages/remote/src/client.ts'), `/** @typert schema */ +export interface ClientMarker { + readonly ready: boolean +} +`) + + expect(new WorkspaceTypertGenerator(root).generate().map(artifact => artifact.face)) + .toEqual(['host', 'client']) + }) + it.each([ { name: 'missing binding', diff --git a/packages/typert/registry/src/service.ts b/packages/typert/registry/src/service.ts index 2f85138edd..3631253342 100644 --- a/packages/typert/registry/src/service.ts +++ b/packages/typert/registry/src/service.ts @@ -600,7 +600,7 @@ function validateCodec(codec: InvocationDescriptor['result'], subject: string): } function validateWireName(subject: string, value: string): void { - if (!/^[A-Za-z0-9_$.-]+$/.test(value)) { + if (value === '.' || value === '..' || !/^[A-Za-z0-9_$.-]+$/.test(value)) { throw new Error(`typert: invalid ${subject} "${value}" — must contain only RPC endpoint segment characters`) } } diff --git a/packages/typert/registry/tests/typert.spec.ts b/packages/typert/registry/tests/typert.spec.ts index 6661cbeeb4..29654babf7 100644 --- a/packages/typert/registry/tests/typert.spec.ts +++ b/packages/typert/registry/tests/typert.spec.ts @@ -247,7 +247,7 @@ describe('TypertRegistry', () => { })).toThrow('endpoint "goals/create" is already registered') }) - it.each(['create#v2', 'create goal'])('rejects untransportable invocation method %s', async (method) => { + it.each(['create#v2', 'create goal', '.', '..'])('rejects untransportable invocation method %s', async (method) => { const ctx = await makeCtx() expect(() => ctx.typert.remotes.register({ package: '@fixture/invalid-endpoint', diff --git a/packages/typert/type-meta/src/index.ts b/packages/typert/type-meta/src/index.ts index 67a4169f96..3d782dbb77 100644 --- a/packages/typert/type-meta/src/index.ts +++ b/packages/typert/type-meta/src/index.ts @@ -15,7 +15,7 @@ const TYPERT_REMOTE_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/ * @returns whether the value can cross the shared RPC carrier unchanged. */ export function isTypeRTRemoteSegment(value: string): boolean { - return TYPERT_REMOTE_SEGMENT_PATTERN.test(value) + return value !== '.' && value !== '..' && TYPERT_REMOTE_SEGMENT_PATTERN.test(value) } export type { diff --git a/packages/typert/type-meta/tests/type-meta.spec.ts b/packages/typert/type-meta/tests/type-meta.spec.ts index 757488024d..b84b76300c 100644 --- a/packages/typert/type-meta/tests/type-meta.spec.ts +++ b/packages/typert/type-meta/tests/type-meta.spec.ts @@ -164,6 +164,8 @@ describe('type-meta Remote declarations', () => { expect(() => Remote('bad/name')).toThrow('export name') expect(() => Remote('bad#name')).toThrow('export name') expect(() => Remote('bad name')).toThrow('export name') + expect(() => Remote('.')).toThrow('export name') + expect(() => Remote('..')).toThrow('export name') expect(() => RemoteContext('' as 'metaFixture')).toThrow('Context key') expect(() => RemoteContext('metaFixture', 'bad/name')).toThrow('export name') From d9413502278318ad27a849b01a2c59b8cefaea24 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:51:49 +0800 Subject: [PATCH 083/104] fix(typert): preserve remote lookup semantics --- ...08-02-typert-remote-method-calls.i18n.yaml | 4 +- .../2026-08-02-typert-remote-method-calls.md | 12 ++- ...026-08-02-typert-remote-method-calls.zh.md | 12 ++- docs/api-gateway.i18n.yaml | 4 +- docs/api-gateway.md | 6 +- docs/api-gateway.zh.md | 6 +- docs/cordis-catalog/services.md | 6 +- docs/core-data-structures/typert.i18n.yaml | 4 +- docs/core-data-structures/typert.md | 6 +- docs/core-data-structures/typert.zh.md | 6 +- packages/client/ui-goal/src/client/index.ts | 10 +- .../ui-goal/tests/browser-plugin.spec.tsx | 27 ++++-- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/host/api-gateway/README.i18n.yaml | 4 +- packages/host/api-gateway/README.md | 7 +- packages/host/api-gateway/README.zh.md | 7 +- packages/host/api-gateway/src/index.ts | 17 +++- packages/host/api-gateway/src/types.ts | 2 +- .../host/api-gateway/tests/client.spec.ts | 21 ++++ .../host/api-gateway/tests/gateway.spec.ts | 44 ++++++++- packages/host/apiproxy/package.json | 2 + packages/host/apiproxy/src/api-proxy.ts | 18 ++++ .../apiproxy/tests/api-proxy-cold.spec.ts | 96 +++++++++++++++++++ packages/host/apiproxy/tsconfig.json | 6 ++ packages/typert/registry/README.i18n.yaml | 4 +- packages/typert/registry/README.md | 1 + packages/typert/registry/README.zh.md | 1 + packages/typert/registry/src/service.ts | 56 ++++++++++- packages/typert/registry/tests/typert.spec.ts | 34 +++++++ packages/typert/type-meta/README.i18n.yaml | 4 +- packages/typert/type-meta/README.md | 2 +- packages/typert/type-meta/README.zh.md | 2 +- packages/typert/type-meta/src/index.ts | 20 ++++ packages/typert/type-meta/src/types.ts | 31 +++++- pnpm-lock.yaml | 6 ++ 35 files changed, 425 insertions(+), 65 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index 02e6c428ff..c76dabca3c 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: ddc93b4fc672f320b4e3dc3e11586d92604e6aa4 -2026-08-02-typert-remote-method-calls.zh.md: 808c7d54bff19d9a4e9cf924769df1d405d997b5 +2026-08-02-typert-remote-method-calls.md: d91f6f173c1b56efcd21d3136392837e61f54aae +2026-08-02-typert-remote-method-calls.zh.md: 0c548522d1137f0e0002a740d12ca0b796da5e39 diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index ddc93b4fc6..d91f6f173c 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -154,7 +154,7 @@ Descriptors exist only in the local registry on each side. The wire carries only ```text ctx.typert.local 当前进程自己的 Host 或 Client reflection ctx.typert.remotes 消费端显式 mount 的对端 Remote contribution -ctx.typert.lookups wire ID 到 Host 活对象的 provider +ctx.typert.lookups wire ID 到 Host 对象的 provider 与组合策略 ctx.typert.contexts Host Context resolver 与 Client Context binder ``` @@ -162,6 +162,8 @@ Every registration returns a disposer owned by the caller's Cordis fiber. Client The lookup registry retains the stable wire declaration after its live resolver unloads. SRC parsing continues to classify the parameter as a lookup, while invocation fails with `lookup-unavailable`; it never reclassifies the incoming ID as an ordinary JSON business object. Re-registering the same key with different parameter, wire, or canonical type symbols fails for the lifetime of that TypeRT Service. +Business-object packages own stable declarations and default resolvers through `register()`; Host composition supplies an effect-scoped asynchronous policy for the same key through `configure()`. Configuration may precede provider registration, but does not by itself make a lookup available without a live provider; unloading the configuration restores the provider's default resolver. The standard Web Host's API Proxy configures the same `agentFor()` for `agent` and `session`: live Agents are reused, ordinary cold sessions are resumed automatically, concurrent resumes are deduplicated by Session ID, and the subagent ownership fence returns the existing `agent-busy`. The `session` resolver returns the resolved Agent's Session, so the two parameter kinds do not create separate resume lifecycles. + The registry's Host root entry has the complete `TypeRTService` interface merge. The registry implementation shared by Host and Client lives in a separate module without environment declarations. The registry's `/client` entry imports only that shared implementation and does not pass through the Host root entry, so it cannot bring Host Cordis declarations into the Client Program. ## Canonical types, symbols, and Zod @@ -432,7 +434,7 @@ ctx.api.goals.create(sessionId, request, signal?) → Client result codec 验证并返回 CreateGoalResult ``` -Remote does not define a second-layer `{ ok, value/error }` response. Successful values and Gateway errors use the existing RPC response's `result` directly. The current adapter converts every Gateway and business-invocation failure to the existing `RpcError` envelope with `code: 'internal'`; the Gateway's structured error category remains available only in-process, while the message carries the diagnostic across Connection. +Remote does not define a second-layer `{ ok, value/error }` response. Successful values and Gateway errors use the existing RPC response's `result` directly. The adapter converts ordinary Gateway and business-invocation failures to the existing `RpcError` envelope with `code: 'internal'`; an existing RPC error carried by a resolver in `TypeRTLookupFailure` is returned unchanged, preserving stable error codes for cold-resume failures and ownership fences. The Gateway's structured error category remains available only in-process, while the message carries the diagnostic across Connection. The Gateway does not handle per-method permissions, caller identity, idempotency, or long-lived connection state. It only propagates cooperative cancellation from Connection into explicitly cancellation-aware business methods. TypeRT endpoints use Connection's trusted-host policy; unclaimed endpoints retain the legacy API Proxy's trust and privileged-method policies. Connection's WebSocket migration remains separate follow-up work. @@ -451,11 +453,12 @@ The Gateway registers only its ownership matcher and RPC handler with Connection - `@deepseek-ai/dsh-client-remotes`: the only Remote facade depended on by Client business code; directly depends on the Gateway Client face, selects `/remote` contributions, and exposes the merged API types to business packages. - Connection: owns the single HTTP Server/future WebSocket carrier, shared `/api` route and composite FetchHandler, API Proxy fallback, RPC envelope, rpcId, serialization, trust, and error transport. - Business-object packages such as Agent/Session: own lookup, Context providers, canonical ID types, and public type-only entries. +- API Proxy Host composition: configures cold resume, concurrent deduplication, and subagent ownership policy for `agent`/`session` lookups through the existing `agentFor()`. - Business Service packages: declare bindings, Remote methods, and their request/result types, and export the generated `/remote` subpath. ## Shipped scope and deferred work -The shipped vertical path is `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`. The same direct descriptor with an Agent lookup supports both `ctx.api.goals.create(agentId, request)` and `agentCtx.goals.create(request)`. `@RemoteContext('agent')` remains the distinct scoped-receiver mode. +The shipped vertical path is `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`. The same direct descriptor with an Agent lookup supports both `ctx.api.goals.create(agentId, request)` and `agentCtx.goals.create(request)`. Ordinary cold sessions are resumed through `agentFor()` during lookup, while subagent-owned identities retain the existing `agent-busy` fence; `@RemoteContext('agent')` remains the distinct scoped-receiver mode. Connection supplies the shared-channel interceptor and current HTTP carrier mapping. WebSocket migration, the TUI runtime and carrier, TUI Agent Scope wiring, Permission/Approval state machines, Session event streams, call authorization, retries, idempotency, and cross-version protocol compatibility remain outside this decision. @@ -486,6 +489,7 @@ Connection supplies the shared-channel interceptor and current HTTP carrier mapp - Importing `@deepseek-ai/dsh-goal/remote` adds the strict `api.goals.create(...)` type and declaration navigation to `remoteExportCreate`; omitting that import omits the namespace. - Mounting the same import's JS contribution supplies endpoint, parameter, result, lookup, Context, and Zod reflection and materializes the call without a handwritten stub. - Root and Agent-scoped calls cross the real shared `/api` carrier, resolve `agentId` to the live Agent, invoke the original Goal receiver, and return through the existing RPC envelope. +- Agent and Session lookups share a single in-flight cold-session resume; ordinary cold sessions receive restored objects, while both cold and live subagent identities return `agent-busy` before business invocation. - The Remote artifacts and maps contain only marked methods and no Browser dependency, preserving the same consumer boundary for a future TUI. - Lifecycle tests withdraw and remount descriptors, Services, lookups, Context providers, and Client namespaces; unavailable dependencies fail without stale calls or raw-ID fallback. - Cancellation tests cover strict generation, SRC final-name recognition, Client signal fusion, Connection-to-Gateway propagation, and Host injection outside wire `args`. @@ -514,3 +518,5 @@ Remote endpoints use Connection's `trusted-host` authority. Loopback is accepted `hasSeen()` favors strict-definition safety over SRC availability. While a strict descriptor is withdrawn, such as during HMR, the Gateway continues to claim the endpoint and reports it unavailable instead of falling back to a weak SRC descriptor. Re-registration restores it; only a TypeRT registry restart forgets the historical strict definition. Cancellation-aware Remote signatures receive Connection's request `AbortSignal`, so an HTTP disconnect or Client-side abort reaches ongoing business work without entering the JSON protocol. Cancellation remains cooperative: methods without the reserved final parameter continue running, and a method that receives the signal must pass it to its own cancellable operations or observe it directly. + +Lookup configuration currently operates at key granularity, so every `agent` or `session` parameter uses the same cold-resume policy. A specific Remote that requires live-only semantics must wait for an explicit per-parameter or per-endpoint policy; the business implementation cannot be left to guess whether the object was just resumed. diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index 808c7d54bf..0c548522d1 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -154,7 +154,7 @@ descriptor 只存在于两端本地 registry。wire 上只有 `/api` channel、e ```text ctx.typert.local 当前进程自己的 Host 或 Client reflection ctx.typert.remotes 消费端显式 mount 的对端 Remote contribution -ctx.typert.lookups wire ID 到 Host 活对象的 provider +ctx.typert.lookups wire ID 到 Host 对象的 provider 与组合策略 ctx.typert.contexts Host Context resolver 与 Client Context binder ``` @@ -162,6 +162,8 @@ ctx.typert.contexts Host Context resolver 与 Client Context binder lookup 注册表会在活 resolver 卸载后保留稳定的 wire 声明。SRC 解析仍会把该参数归类为 lookup,而调用会以 `lookup-unavailable` 失败;系统绝不会把传入的 ID 重新归类为普通 JSON 业务对象。在同一个 TypeRT Service 的生命周期内,以不同参数、wire 或规范类型 symbol 重新注册同一 key 会直接失败。 +业务对象包通过 `register()` 拥有稳定声明和默认 resolver;Host 组合通过 `configure()` 为同一个 key 提供 effect-scoped 异步策略。配置可以先于 provider 注册,但没有活 provider 时不会单独形成可用 lookup;配置卸载后恢复 provider 默认 resolver。标准 Web Host 的 API Proxy 为 `agent` 和 `session` 配置同一套 `agentFor()`:live Agent 直接复用,普通冷会话自动恢复,并发恢复按 Session ID 去重,subagent ownership fence 则返回既有 `agent-busy`。`session` resolver 返回解析所得 Agent 的 Session,因而两种参数不会产生两套恢复生命周期。 + Registry 的 Host 根入口拥有完整 `TypeRTService` interface merge;Host 与 Client 共用的 registry 实现位于无环境声明的独立模块。Registry `/client` 入口只引用该共享实现,不经过 Host 根入口,因此不会把 Host Cordis 声明带入 Client Program。 ## 唯一类型、符号与 Zod @@ -432,7 +434,7 @@ ctx.api.goals.create(sessionId, request, signal?) → Client result codec 验证并返回 CreateGoalResult ``` -Remote 不定义第二层 `{ ok, value/error }` response。成功值和 Gateway 错误直接使用既有 RPC response 的 `result`。当前 adapter 把所有 Gateway 与业务调用失败转换为既有 `RpcError` envelope,并统一使用 `code: 'internal'`;Gateway 的结构化错误分类仅在进程内保留,诊断信息则通过 message 跨 Connection 传递。 +Remote 不定义第二层 `{ ok, value/error }` response。成功值和 Gateway 错误直接使用既有 RPC response 的 `result`。adapter 把普通 Gateway 与业务调用失败转换为既有 `RpcError` envelope,并统一使用 `code: 'internal'`;resolver 通过 `TypeRTLookupFailure` 携带的既有 RPC error 则原样返回,使冷恢复失败和 ownership fence 保持稳定错误码。Gateway 的结构化错误分类仅在进程内保留,诊断信息则通过 message 跨 Connection 传递。 Gateway 不处理逐方法权限、调用者身份、幂等或长连接状态。它只把 Connection 的协作式取消传播给显式支持取消的业务方法。TypeRT endpoint 使用 Connection 的 trusted-host 策略;未认领 endpoint 保留旧 API Proxy 的 trust 和 privileged-method 策略。Connection/WebSocket 迁移后续独立完成。 @@ -451,11 +453,12 @@ Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 H - `@deepseek-ai/dsh-client-remotes`:Client 业务唯一依赖的 Remote facade;直接依赖 Gateway Client face,选择 `/remote` contributions,并向业务包传递合并后的 API 类型。 - Connection:拥有唯一 HTTP Server/未来 WebSocket carrier、共享 `/api` route 与复合 FetchHandler、API Proxy 回退、RPC envelope、rpcId、序列化、trust 和错误传输。 - Agent/Session 等业务对象包:拥有 lookup、Context provider、唯一 ID 类型和纯类型公共出口。 +- API Proxy Host 组合:用既有 `agentFor()` 配置 `agent`/`session` lookup 的冷恢复、并发去重和 subagent ownership 策略。 - 业务 Service 包:声明 binding、Remote 方法及其 request/result 类型,并导出生成的 `/remote` 子路径。 ## 已交付范围与后续工作 -已交付的纵向链路是 `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`。同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.api.goals.create(agentId, request)` 与 `agentCtx.goals.create(request)`。`@RemoteContext('agent')` 仍是独立的 scoped receiver 模式。 +已交付的纵向链路是 `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`。同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.api.goals.create(agentId, request)` 与 `agentCtx.goals.create(request)`。普通冷会话在 lookup 时通过 `agentFor()` 恢复,subagent-owned identity 保持既有 `agent-busy` fence;`@RemoteContext('agent')` 仍是独立的 scoped receiver 模式。 Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebSocket 迁移、TUI runtime 与 carrier、TUI Agent Scope 接线、Permission/Approval 状态机、Session 事件流、调用授权、重试、幂等及跨版本协议兼容均不属于本决策。 @@ -486,6 +489,7 @@ Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebS - 导入 `@deepseek-ai/dsh-goal/remote` 会加入严格的 `api.goals.create(...)` 类型,并可通过 declaration 导航到 `remoteExportCreate`;不导入时不会出现该 namespace。 - 挂载同一次 import 得到的 JS contribution 会提供 endpoint、参数、结果、lookup、Context 和 Zod 反射,并在无需手写 stub 的情况下实体化调用。 - Root 与 Agent-scoped 调用会经过真实的共享 `/api` carrier,将 `agentId` 解析为活 Agent,调用原始 Goal receiver,并通过既有 RPC envelope 返回。 +- Agent 与 Session lookup 会共享同一次并发冷恢复;普通冷会话得到恢复后的对象,冷态或 live subagent identity 均在业务调用前返回 `agent-busy`。 - Remote 产物与 map 仅包含已标记的方法,不依赖 Browser,从而为未来 TUI 保留相同的消费方边界。 - 生命周期测试会撤回并重新挂载 descriptor、Service、lookup、Context 提供方和 Client namespace;依赖不可用时,调用会失败,且不会使用陈旧调用或回退原始 ID。 - 取消测试覆盖严格生成、SRC 末位参数名识别、Client signal 合并、Connection 到 Gateway 的传播,以及 Host 在 wire `args` 之外的注入。 @@ -514,3 +518,5 @@ Remote endpoint 使用 Connection 的 `trusted-host` authority。系统默认接 `hasSeen()` 优先保障 strict definition 的安全性,而非 SRC 可用性。strict descriptor 撤回时(例如 HMR 期间),Gateway 会继续认领 endpoint 并报告不可用,而不会回退到弱 SRC descriptor。重新注册即可恢复;只有重启 TypeRT 注册表才会忘记历史 strict definition。 支持取消的 Remote 签名会接收 Connection 请求的 `AbortSignal`,因此 HTTP 断连或 Client 侧 abort 能在不进入 JSON 协议的情况下传递到正在进行的业务工作。取消仍是协作式的:没有保留末位参数的方法会继续运行;收到 signal 的方法必须将它传给自身支持取消的操作,或自行观测它。 + +lookup 配置当前以 key 为粒度,因此每个 `agent` 或 `session` 参数都采用同一套冷恢复策略。需要 live-only 语义的特定 Remote 必须等待显式的逐参数或逐 endpoint 策略,不能靠业务实现猜测对象是否刚被恢复。 diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index 87abb10c88..58891890d3 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: 76af93880d278a17dc46370fd5065fdcdadb9fb6 -api-gateway.zh.md: d447cea6b64bf88084f86a210a5f654bd9445d6c +api-gateway.md: 2e0717fd7b0e5b9ca33d650ffad7ac454046f780 +api-gateway.zh.md: 4d1beebf92cae702dac323cdd974b6220091a214 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 76af93880d..2e0717fd7b 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -8,7 +8,7 @@ This is the current-state reference for the TypeRT API Gateway. It describes how Business services use `@Remote` or `@RemoteContext` to select the methods exposed to the Client. Unmarked methods do not enter the generated Client types or runtime contributions and cannot be called through `ctx.api`. -`@Remote` denotes calling a Cordis service registered on the root Host Context. Complex Host objects cannot cross the wire directly; the business package must declare their association with a wire identity through `TypeRTLookupMap` and register a resolution provider with `ctx.typert.lookups` at runtime. For example, an `Agent` parameter named `agent` in the Host signature produces an `agentId` wire field, and the Gateway resolves that id to the current live object before invoking the business method. +`@Remote` denotes calling a Cordis service registered on the root Host Context. Complex Host objects cannot cross the wire directly; the business package must declare their association with a wire identity through `TypeRTLookupMap` and register a default resolution provider with `ctx.typert.lookups` at runtime. For example, an `Agent` parameter named `agent` in the Host signature produces an `agentId` wire field, and the Gateway resolves that id to a Host object before invoking the business method. Host composition can use `ctx.typert.lookups.configure()` to override the resolution policy for a lookup key without changing the parameter name, wire field, or canonical type symbol owned by the business package. `@RemoteContext(key)` first resolves an identity to a scoped Context through `ctx.typert.contexts`, then obtains the service from that Context and invokes the method. It applies when the method itself depends on scoped composition and does not need to receive objects such as `Agent` explicitly. @@ -117,6 +117,8 @@ The Connection performs the unified trust check for `/api` before the HTTP bridg 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 at the boundary 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 standard Web Host's API Proxy configures the same `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. ## SRC development fallback @@ -155,3 +157,5 @@ The running Client watcher consumes these generated files when it rebundles; wit ## Boundaries 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. + +Lookup policy is currently configured per key, so all `agent` or `session` parameters share the cold-resume behavior. If a Remote endpoint must accept live objects only, an explicit per-parameter or per-endpoint policy must be added later; 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 d447cea6b6..4d1beebf92 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -8,7 +8,7 @@ 业务 Service 通过 `@Remote` 或 `@RemoteContext` 选择对 Client 开放的方法。未标记的方法不会进入生成的 Client 类型或运行时贡献,也不能通过 `ctx.api` 调用。 -`@Remote` 表示调用根 Host Context 中注册的 Cordis Service。复杂的 Host 对象不能直接跨 wire 传输;业务包必须通过 `TypeRTLookupMap` 声明它与 wire identity 的关联,并在运行时向 `ctx.typert.lookups` 注册解析提供方。例如 `Agent` 参数在 Host 签名中名为 `agent`,生成的 wire 字段为 `agentId`,Gateway 在调用业务方法前将 id 解析为当前的实时对象。 +`@Remote` 表示调用根 Host Context 中注册的 Cordis Service。复杂的 Host 对象不能直接跨 wire 传输;业务包必须通过 `TypeRTLookupMap` 声明它与 wire identity 的关联,并在运行时向 `ctx.typert.lookups` 注册默认解析提供方。例如 `Agent` 参数在 Host 签名中名为 `agent`,生成的 wire 字段为 `agentId`,Gateway 在调用业务方法前将 id 解析为 Host 对象。Host 组合可以用 `ctx.typert.lookups.configure()` 覆盖某个 lookup key 的解析策略,而不改变业务包拥有的参数名、wire 字段或规范类型 symbol。 `@RemoteContext(key)` 表示先通过 `ctx.typert.contexts` 把 identity 解析为一个作用域 Context,再从该 Context 取得 Service 并调用方法。它适用于方法本身依赖作用域组合、而不需要显式接收 `Agent` 等对象的情形。 @@ -117,6 +117,8 @@ Connection 在 HTTP bridge 之前执行 `/api` 的统一信任检查,再在共 Gateway 每次调用都从当前注册表解析描述符和实时 Service,不缓存业务对象。它要求 `args` 的字段集合与描述符完全一致,先用 codec 校验 wire 值,再通过注册的 lookup 或 Context provider 解析对象或接收者,最后调用 binding 指向的 Service 方法并校验返回值。缺少 provider、identity 未命中、binding 不一致、参数多缺、schema 失败和方法不存在都在进入或离开业务边界时失败。 +lookup provider 的 `register()` 同时提供稳定声明和默认 resolver;`configure()` 提供由 Host 组合拥有、可异步执行且受 effect 生命周期约束的 resolver。配置可以先于 provider 挂载;没有 provider 时调用仍以 `lookup-unavailable` 失败,配置卸载后则恢复 provider 默认策略。标准 Web Host 的 API Proxy 为 `agent` 与 `session` 配置同一套 `agentFor()` 语义:复用 live Agent,自动恢复普通冷会话,对并发恢复去重,并拒绝由 subagent routing 拥有的 identity;`session` lookup 返回该 Agent 的 Session。恢复失败和 ownership fence 通过既有 RPC error 原样返回,不折叠为 Gateway 的 `internal` 错误。 + Client 卸载一个贡献时会一起移除描述符和具体方法,中止其进行中的调用,并使外部仍持有的旧方法句柄拒绝继续调用。Host 上已经注册过的严格 endpoint 被撤回后也不会降级到 SRC 推断,以免热卸载悄然降低校验强度。 ## SRC 开发回退 @@ -155,3 +157,5 @@ pnpm run build:lib:contracts ## 边界 Remote 只处理有单个请求与单个结果的一元方法调用。Session event stream、分页、增量 reduce、projection 和实体子流需要独立的数据协议与注册模型;即使它们复用 Connection,也不应伪装成 Remote 方法或放入调用描述符。 + +当前 lookup 策略按 key 配置,因此所有 `agent` 或 `session` 参数共享冷恢复行为。某个 Remote endpoint 若必须只接受 live 对象,需要后续增加显式的逐参数或逐 endpoint 策略,不能通过业务方法内部猜测恢复来源。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 8a646fc177..8acd669131 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2593,7 +2593,7 @@ listPackages(filter: TypertPackageFilter = {}): TypertPackageRecord[] toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema.BaseSchema ``` -Source: [`packages/typert/registry/src/service.ts:346`](../../packages/typert/registry/src/service.ts) +Source: [`packages/typert/registry/src/service.ts:400`](../../packages/typert/registry/src/service.ts) ## `ctx.typertGateway` — `TypertGatewayService` @@ -2604,12 +2604,12 @@ Resolve strict generated definitions or conservative SRC markers against current * Invoke one live Remote method through strict generated reflection or SRC markers. * @param request - decoded endpoint and exact named wire arguments. * @returns the validated business result. - * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; business errors retain their identity. + * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity. */ async invoke(request: InvokeRemoteRequest): Promise ``` -Source: [`packages/host/api-gateway/src/index.ts:76`](../../packages/host/api-gateway/src/index.ts) +Source: [`packages/host/api-gateway/src/index.ts:78`](../../packages/host/api-gateway/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` diff --git a/docs/core-data-structures/typert.i18n.yaml b/docs/core-data-structures/typert.i18n.yaml index a5484d06c4..5b0b70de54 100644 --- a/docs/core-data-structures/typert.i18n.yaml +++ b/docs/core-data-structures/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/core-data-structures/typert.md -typert.md: da6e229ff6a2300c36f5734ad05c621a5e63082d -typert.zh.md: b3b0e8897756b5b4f9b645522cc5a1b27eac1d33 +typert.md: 1ff0fe80e483d481f686336c86038cdd169ecdbc +typert.zh.md: 3cc0aa26406e01db5a6c05074210fc9d40b8ec00 diff --git a/docs/core-data-structures/typert.md b/docs/core-data-structures/typert.md index da6e229ff6..1ff0fe80e4 100644 --- a/docs/core-data-structures/typert.md +++ b/docs/core-data-structures/typert.md @@ -114,7 +114,7 @@ interface InvocationDescriptor { ## TypeRT registry -`ctx.typert` separates current-environment descriptors, explicitly selected Remote contributions, live lookup providers, and scoped Context providers. Registrations are Cordis-owned effects and return awaitable disposers. +`ctx.typert` separates current-environment descriptors, explicitly selected Remote contributions, lookup providers, and scoped Context providers. A lookup provider owns the stable wire declaration and default resolver; Host composition can configure an effect-scoped synchronous or asynchronous resolver for the same key, and unloading that configuration restores the default policy. Registrations are Cordis-owned effects and return awaitable disposers. ```ts type-equiv /** Minimal TypeRT runtime consumed through dependency inversion. */ @@ -135,7 +135,7 @@ interface TypeRTRemoteNamespaceMap {} ## Host Gateway -Connection decodes its carrier envelope before calling `ctx.typertGateway`. The request carries exact named wire fields and the carrier's cancellation signal separately; infrastructure and boundary failures use the Gateway's in-process error taxonomy, although the current RPC adapter folds them into the transport's `internal` error code. +Connection decodes its carrier envelope before calling `ctx.typertGateway`. The request carries exact named wire fields and the carrier's cancellation signal separately; infrastructure and boundary failures use the Gateway's in-process error taxonomy, ordinary exceptions are folded by the RPC adapter into the transport's `internal` error code, and existing RPC errors carried by lookup policy through `TypeRTLookupFailure` are returned unchanged. ```ts type-equiv /** One Remote method request after a carrier has decoded its envelope. */ @@ -180,7 +180,7 @@ interface TypertGateway { * Invoke one live Remote method without assuming a carrier or response envelope. * @param request - decoded endpoint and named wire arguments. * @returns the validated business result. - * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; business errors retain their identity. + * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity. */ invoke(request: InvokeRemoteRequest): Promise } diff --git a/docs/core-data-structures/typert.zh.md b/docs/core-data-structures/typert.zh.md index b3b0e88977..3cc0aa2640 100644 --- a/docs/core-data-structures/typert.zh.md +++ b/docs/core-data-structures/typert.zh.md @@ -114,7 +114,7 @@ interface InvocationDescriptor { ## TypeRT 注册表 -`ctx.typert` 分开保存当前环境的 descriptor、显式选择的 Remote contribution、活 lookup 提供方与 scoped Context 提供方。各项注册都是由 Cordis 持有的 effect,并返回可等待的 disposer。 +`ctx.typert` 分开保存当前环境的 descriptor、显式选择的 Remote contribution、lookup 提供方与 scoped Context 提供方。lookup 提供方拥有稳定 wire 声明和默认 resolver;Host 组合可以为同一个 key 配置 effect-scoped 同步或异步 resolver,配置卸载后恢复默认策略。各项注册都是由 Cordis 持有的 effect,并返回可等待的 disposer。 ```ts type-equiv /** Minimal TypeRT runtime consumed through dependency inversion. */ @@ -135,7 +135,7 @@ interface TypeRTRemoteNamespaceMap {} ## Host Gateway -Connection 会先解码 carrier envelope,再调用 `ctx.typertGateway`。请求将精确的具名 wire 字段与 carrier 的取消 signal 分开携带;基础设施与边界失败使用 Gateway 的进程内错误分类体系,但当前 RPC 适配器会把这些错误折叠为传输层的 `internal` 错误码。 +Connection 会先解码 carrier envelope,再调用 `ctx.typertGateway`。请求将精确的具名 wire 字段与 carrier 的取消 signal 分开携带;基础设施与边界失败使用 Gateway 的进程内错误分类体系,普通异常由 RPC 适配器折叠为传输层的 `internal` 错误码,lookup 策略通过 `TypeRTLookupFailure` 携带的既有 RPC error 则原样返回。 ```ts type-equiv /** One Remote method request after a carrier has decoded its envelope. */ @@ -180,7 +180,7 @@ interface TypertGateway { * Invoke one live Remote method without assuming a carrier or response envelope. * @param request - decoded endpoint and named wire arguments. * @returns the validated business result. - * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; business errors retain their identity. + * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity. */ invoke(request: InvokeRemoteRequest): Promise } diff --git a/packages/client/ui-goal/src/client/index.ts b/packages/client/ui-goal/src/client/index.ts index 19b88139b5..8fcfd292d2 100644 --- a/packages/client/ui-goal/src/client/index.ts +++ b/packages/client/ui-goal/src/client/index.ts @@ -70,8 +70,6 @@ function isRemoteError(value: unknown): value is { readonly code: string; readon export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-goal: dictionaries') - const { goals } = ctx.api - const sessions = ctx.sessions /** The session's current projected CAS ref, read at verb call time (no staleness fence: the RPC's CAS is the guard). */ @@ -96,22 +94,22 @@ export function apply(ctx: ClientContext): void { onEdit: async (objective) => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(goals.edit(sessionId, ref, { objective })) + return settle(ctx.api.goals.edit(sessionId, ref, { objective })) }, onPause: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(goals.pause(sessionId, ref)) + return settle(ctx.api.goals.pause(sessionId, ref)) }, onResume: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(goals.resume(sessionId, ref)) + return settle(ctx.api.goals.resume(sessionId, ref)) }, onClear: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(goals.clear(sessionId, ref)) + return settle(ctx.api.goals.clear(sessionId, ref)) }, }), }, GoalDock)) diff --git a/packages/client/ui-goal/tests/browser-plugin.spec.tsx b/packages/client/ui-goal/tests/browser-plugin.spec.tsx index eddb272be4..11c95e27d9 100644 --- a/packages/client/ui-goal/tests/browser-plugin.spec.tsx +++ b/packages/client/ui-goal/tests/browser-plugin.spec.tsx @@ -64,12 +64,16 @@ async function bench(options: { } } const ref = { id: 'g-1', revision: 3 } - ctx.provide('api', { goals: { - edit: answer('goals/edit', { ref }), - pause: answer('goals/pause', { ref }), - resume: answer('goals/resume', { ref }), - clear: answer('goals/clear', ref), - } }) + const goals = (prefix: string) => ({ + edit: answer(`${prefix}/edit`, { ref }), + pause: answer(`${prefix}/pause`, { ref }), + resume: answer(`${prefix}/resume`, { ref }), + clear: answer(`${prefix}/clear`, ref), + }) + let activeGoals = goals('goals') + ctx.provide('api', { + get goals() { return activeGoals }, + }) await ctx.plugin(SlotsService).await() ctx.slots.register({ name: 'root', children: { 'conversation.input.dock': { kind: 'list', scope: 'session' } }, @@ -90,6 +94,7 @@ async function bench(options: { ctx, fiber, calls, + remountGoals: () => { activeGoals = goals('remounted-goals') }, entry: () => { const entry = ctx.slots.entries('conversation.input.dock')[0] if (entry === undefined) return undefined @@ -126,6 +131,16 @@ describe('ui-goal browser plugin', () => { expect(b.calls[3]?.args).toEqual(['s1', ref]) }) + it('verbs read a remounted Remote namespace at action time', async () => { + const b = await bench({ projection: makeProjection() }) + await b.fiber.await() + const verbs = b.entry()!.inject!(sid('s1')) + b.remountGoals() + + expect(await verbs.onPause()).toEqual({ ok: true }) + expect(b.calls).toMatchObject([{ method: 'remounted-goals/pause' }]) + }) + it('a null or absent projection short-circuits every verb without touching the wire', async () => { for (const projection of [null, undefined]) { const b = await bench({ projection }) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 2627d43b69..b7fd6d3c5a 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1160,7 +1160,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ methods: [ { signature: 'async invoke(request: InvokeRemoteRequest): Promise', - jsDoc: '/**\n * Invoke one live Remote method through strict generated reflection or SRC markers.\n * @param request - decoded endpoint and exact named wire arguments.\n * @returns the validated business result.\n * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; business errors retain their identity.\n */', + jsDoc: '/**\n * Invoke one live Remote method through strict generated reflection or SRC markers.\n * @param request - decoded endpoint and exact named wire arguments.\n * @returns the validated business result.\n * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity.\n */', }, ], }, diff --git a/packages/host/api-gateway/README.i18n.yaml b/packages/host/api-gateway/README.i18n.yaml index 273a493c24..8d8d699c7a 100644 --- a/packages/host/api-gateway/README.i18n.yaml +++ b/packages/host/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/host/api-gateway/README.md -README.md: 43e8f464e2a2790d05628a7fba61143a6a5ab26a -README.zh.md: 761045d0c1afc17dfc230f9f45849c46e4e579fc +README.md: eb48c29628d39e381235b1f72754eb114960b1ad +README.zh.md: e53bb6c216e42fe2e970bf2cb80eac9ea7426497 diff --git a/packages/host/api-gateway/README.md b/packages/host/api-gateway/README.md index 43e8f464e2..eb48c29628 100644 --- a/packages/host/api-gateway/README.md +++ b/packages/host/api-gateway/README.md @@ -8,9 +8,9 @@ Two-sided Remote control for Host and Client Cordis environments. The Host entry `ctx.typertGateway.invoke()` resolves the current descriptor and Cordis Service for each call, validates exact named arguments, resolves registered object or Context identities, invokes the public business method, and validates its result. Business Services extend `GatewayService` and mark methods with `@Remote` or `@RemoteContext` from [`dsh-type-meta`](../../typert/type-meta/README.md); `bindTypeRTGateway()` remains available when another base class owns inheritance. -Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use registered `ctx.typert.lookups` providers, while `@RemoteContext` resolves its receiver through a registered Host Context provider. 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. +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()`; `@RemoteContext` resolves its receiver through a registered Host Context provider. 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. +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. 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. @@ -32,7 +32,8 @@ No direct effect; invoked business Services own any model-visible result. ## Known Limitations and Deferred Work -- The Connection adapter currently maps dispatch and business failures to the RPC `internal` code with empty details. Structured `TypertGatewayError` categories remain available only to same-process callers. +- The Connection adapter maps ordinary dispatch failures and business exceptions to the RPC `internal` code with empty details; lookup-policy errors carried by `TypeRTLookupFailure` are returned unchanged. Structured `TypertGatewayError` categories remain available only to same-process callers. - SRC mode supports unique identifier parameters without destructuring, defaults, or rest parameters. It validates JSON safety rather than generated business types and never infers optional fields. - Only strict generated contributions can mount on the Client face. SRC markers have no Client codec or type projection. - The package dispatches unary methods only. Incremental Session data uses a separate named-stream protocol over the same Connection. +- 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. diff --git a/packages/host/api-gateway/README.zh.md b/packages/host/api-gateway/README.zh.md index 761045d0c1..e53bb6c216 100644 --- a/packages/host/api-gateway/README.zh.md +++ b/packages/host/api-gateway/README.zh.md @@ -8,9 +8,9 @@ 每次调用时,`ctx.typertGateway.invoke()` 都会解析当前的描述符和 Cordis 服务,校验具名参数是否完全匹配,解析已注册的对象或 Context 身份标识,调用公开的业务方法,并校验其结果。业务服务继承 [`dsh-type-meta`](../../typert/type-meta/README.md) 的 `GatewayService`,并用 `@Remote` 或 `@RemoteContext` 标记方法;已有其他基类时仍可改用 `bindTypeRTGateway()`。 -严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用已向 `ctx.typert.lookups` 注册的提供方,`@RemoteContext` 则通过已注册的 Host Context 提供方解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。 +严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用 `ctx.typert.lookups` 中当前有效的 resolver:业务包注册稳定声明与默认策略,Host 组合可用 effect-scoped `configure()` 覆盖解析行为;`@RemoteContext` 则通过已注册的 Host Context 提供方解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。 -Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领 endpoint 则交给 API Proxy。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 可区分分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。 +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 等策略拒绝保持原错误码。 支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。 @@ -32,7 +32,8 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandle ## 已知限制与延期工作 -- Connection 适配器目前将分发故障和业务故障映射为 RPC 的 `internal` 代码,且不附带详细信息。结构化的 `TypertGatewayError` 类别仅供同进程调用方使用。 +- Connection 适配器将普通分发故障和业务异常映射为 RPC 的 `internal` 代码,且不附带详细信息;`TypeRTLookupFailure` 携带的 lookup 策略错误会原样返回。结构化的 `TypertGatewayError` 类别仅供同进程调用方使用。 - SRC 模式仅支持名称唯一的标识符参数,不支持解构、默认值或剩余参数。它只校验值能否安全表示为 JSON,不校验生成的业务类型,也绝不会推断可选字段。 - Client 侧只能挂载严格模式生成的贡献项。SRC 标记不具备 Client 编解码器或类型投影。 - 该包只分发一元方法。增量会话数据通过同一个 Connection 上独立的具名流协议传输。 +- lookup resolver 按 key 配置;当前无法让单个 Remote 参数或 endpoint 在同一 `agent`/`session` key 下选择 live-only 策略。 diff --git a/packages/host/api-gateway/src/index.ts b/packages/host/api-gateway/src/index.ts index 7dd2410873..8ea26b5990 100644 --- a/packages/host/api-gateway/src/index.ts +++ b/packages/host/api-gateway/src/index.ts @@ -8,6 +8,7 @@ import { Context, Service, symbols } from 'cordis' import type { ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection' import { remoteMethods, + TypeRTLookupFailure, type InvocationDescriptor, type InvocationParameterDescriptor, type TypeRTCodec, @@ -36,6 +37,7 @@ interface ResolvedBinding { } type ConnectionRpcResult = Awaited> +type ConnectionRpcError = Extract['error'] const NEVER_ABORTED_SIGNAL = new AbortController().signal /** Dispatch failure produced outside the invoked business method. */ @@ -126,7 +128,7 @@ export class TypertGatewayService extends Service implements TypertGateway { * Invoke one live Remote method through strict generated reflection or SRC markers. * @param request - decoded endpoint and exact named wire arguments. * @returns the validated business result. - * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; business errors retain their identity. + * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity. */ async invoke(request: InvokeRemoteRequest): Promise { const endpoint = endpointOf(request.namespace, request.method) @@ -142,7 +144,8 @@ export class TypertGatewayService extends Service implements TypertGateway { ) } validateBinding(receiver, descriptor.service, descriptor.namespace, endpoint) - const args = descriptor.parameters.map(parameter => this.resolveParameter(parameter, request.args, endpoint)) + const args = await Promise.all(descriptor.parameters.map(parameter => + this.resolveParameter(parameter, request.args, endpoint))) if (descriptor.cancellation !== undefined) args.push(request.signal ?? NEVER_ABORTED_SIGNAL) const implementation = descriptor.implementation ?? descriptor.method const method = Reflect.get(receiver, implementation) as unknown @@ -375,11 +378,11 @@ export class TypertGatewayService extends Service implements TypertGateway { return context } - private resolveParameter( + private async resolveParameter( parameter: InvocationParameterDescriptor, args: Readonly>, endpoint: string, - ): unknown { + ): Promise { const value = decode(parameter.codec, args[parameter.wire], 'input-invalid', endpoint, parameter.wire) if (parameter.source === 'json') return value const key = parameter.lookup @@ -412,8 +415,9 @@ export class TypertGatewayService extends Service implements TypertGateway { } let resolved: unknown try { - resolved = provider.resolve(value) + resolved = await provider.resolve(value) } catch (cause) { + if (cause instanceof TypeRTLookupFailure) throw cause throw new TypertGatewayError( 'lookup-failed', endpoint, @@ -434,6 +438,9 @@ export class TypertGatewayService extends Service implements TypertGateway { } function rpcFailure(error: unknown): ConnectionRpcResult { + if (error instanceof TypeRTLookupFailure) { + return { ok: false, error: error.failure as ConnectionRpcError } + } return { ok: false, error: { diff --git a/packages/host/api-gateway/src/types.ts b/packages/host/api-gateway/src/types.ts index b7f36eb340..f4bb276c22 100644 --- a/packages/host/api-gateway/src/types.ts +++ b/packages/host/api-gateway/src/types.ts @@ -41,7 +41,7 @@ export interface TypertGateway { * Invoke one live Remote method without assuming a carrier or response envelope. * @param request - decoded endpoint and named wire arguments. * @returns the validated business result. - * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; business errors retain their identity. + * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity. */ invoke(request: InvokeRemoteRequest): Promise } diff --git a/packages/host/api-gateway/tests/client.spec.ts b/packages/host/api-gateway/tests/client.spec.ts index c1f94f2e44..2fbcbb9280 100644 --- a/packages/host/api-gateway/tests/client.spec.ts +++ b/packages/host/api-gateway/tests/client.spec.ts @@ -484,6 +484,27 @@ describe('Client TypeRT API', () => { await retry() }) + it('withdraws a fresh direct namespace when its first method fails to install', async () => { + const ctx = await bench(vi.fn()) + const defineProperty = Object.defineProperty + const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => { + if (key === 'create') throw new Error('fixture direct method installation failure') + return defineProperty(target, key, attributes) + }) + try { + expect(() => ctx.api.mount({ package: '@fixture/direct-method-failure', descriptors: [directDescriptor()] })) + .toThrow('fixture direct method installation failure') + } finally { + spy.mockRestore() + } + + expect((ctx.api as unknown as Record).goals).toBeUndefined() + await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) + const retry = ctx.api.mount({ package: '@fixture/direct-method-retry', descriptors: [directDescriptor()] }) + expect(ctx.api.goals.create).toBeTypeOf('function') + await retry() + }) + it('withdraws a fresh scoped Service when its first method fails to install', async () => { const ctx = await bench(vi.fn()) const defineProperty = Object.defineProperty diff --git a/packages/host/api-gateway/tests/gateway.spec.ts b/packages/host/api-gateway/tests/gateway.spec.ts index aebe23da57..0871dc2761 100644 --- a/packages/host/api-gateway/tests/gateway.spec.ts +++ b/packages/host/api-gateway/tests/gateway.spec.ts @@ -9,6 +9,7 @@ import { bindTypeRTGateway, Remote, RemoteContext, + TypeRTLookupFailure, type InvocationDescriptor, type TypeRTContext, type TypeRTLookup, @@ -91,7 +92,7 @@ class GoalService extends Service { type FakeRpcResult = | { readonly ok: true; readonly value: unknown } - | { readonly ok: false; readonly error: { readonly code: 'internal'; readonly message: string; readonly details: object } } + | { readonly ok: false; readonly error: { readonly code: string; readonly message: string; readonly details: object } } type FakeRpcHandler = (endpoint: string, payload: unknown, signal: AbortSignal) => Promise @@ -568,7 +569,7 @@ describe('TypertGatewayService', () => { registerStrict(ctx, [createDescriptor()]) const throwing = ctx.typert.lookups.register('gatewayFixture', { ...agentLookup({ id: 'agent-1' }), - resolve: () => { throw new Error('lookup failed') }, + resolve: async () => { throw new Error('lookup failed') }, }) const failure = await expectCode(ctx.typertGateway.invoke({ namespace: 'goals', @@ -578,15 +579,26 @@ describe('TypertGatewayService', () => { expect(failure.cause).toEqual(new Error('lookup failed')) await throwing() - ctx.typert.lookups.register('gatewayFixture', { + const missing = ctx.typert.lookups.register('gatewayFixture', { ...agentLookup({ id: 'agent-1' }), - resolve: () => undefined, + resolve: () => Promise.resolve(undefined), }) await expectCode(ctx.typertGateway.invoke({ namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, }), 'lookup-not-found') + await missing() + + ctx.typert.lookups.register('gatewayFixture', { + ...agentLookup({ id: 'agent-1' }), + resolve: async id => ({ id }), + }) + await expect(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: { agentId: 'agent-1', request: { title: 'ship' } }, + })).resolves.toMatchObject({ agentId: 'agent-1', title: 'ship' }) }) it('never downgrades an observed strict endpoint after definition disposal', async () => { @@ -968,6 +980,30 @@ describe('TypertGatewayService', () => { expect(connection.handler).toBeUndefined() }) + it('preserves a lookup policy rejection through the Connection RPC result', async () => { + const ctx = new Context() + await ctx.plugin(TypertRegistry) + await ctx.plugin(FakeConnectionService) + await ctx.plugin(TypertGatewayService) + await ctx.plugin(GoalService) + registerStrict(ctx, [createDescriptor()]) + const failure = { + code: 'agent-busy', + message: 'session is owned by subagent routing', + details: { reason: 'use subagent delivery for this child session' }, + } + ctx.typert.lookups.register('gatewayFixture', { + ...agentLookup({ id: 'agent-1' }), + resolve: () => { throw new TypeRTLookupFailure(failure) }, + }) + const handler = rawConnection(ctx).handler + if (handler === undefined) throw new Error('fixture Connection did not retain the /api interceptor') + + await expect(handler('goals/create', { + args: { agentId: 'agent-1', request: { title: 'ship' } }, + }, new AbortController().signal)).resolves.toEqual({ ok: false, error: failure }) + }) + it('caches SRC ownership until the Cordis Service set changes', async () => { const ctx = new Context() await ctx.plugin(TypertRegistry) diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 426860eebc..ce740a025f 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -56,6 +56,8 @@ "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", + "@deepseek-ai/dsh-typert-registry": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 2a54e2c113..f2c199feca 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -19,6 +19,9 @@ import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-se import { SubagentError } from '@deepseek-ai/dsh-subagent' import type { SubagentListEntry as CatalogSubagentListEntry } from '@deepseek-ai/dsh-subagent' import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' +import { TypeRTLookupFailure } from '@deepseek-ai/dsh-type-meta' +// Type-only: resolves the optional `ctx.typert` lookup-policy composition. +import type {} from '@deepseek-ai/dsh-typert-registry' import { workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId, WorkspaceMoveInvalidError, WorkspaceUnknownSessionError, @@ -1099,6 +1102,21 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } } + // Remote object parameters use the same identity policy as API Proxy methods: + // ordinary cold sessions resume once, while subagent-owned identities retain + // their stable caller-facing rejection. The provider packages continue to + // own wire declarations and live-only defaults; this Host composition owns + // the broader lookup policy. + ctx.inject(['typert'], (typeCtx) => { + const resolveAgent = async (sessionId: SessionId): Promise => { + const found = await agentFor(sessionId) + if ('error' in found) throw new TypeRTLookupFailure(found.error) + return found.agent + } + typeCtx.typert.lookups.configure('agent', resolveAgent) + typeCtx.typert.lookups.configure('session', async sessionId => (await resolveAgent(sessionId)).session) + }) + type SessionReadState = { id: SessionId header: SessionHeader diff --git a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts index 4b6337ede8..e5e137f0c4 100644 --- a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts @@ -11,6 +11,8 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import SessionStore from '@deepseek-ai/dsh-session' import AgentRegistry from '@deepseek-ai/dsh-agent' +import { TypeRTLookupFailure } from '@deepseek-ai/dsh-type-meta' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import { MessageId } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' @@ -180,6 +182,100 @@ describe('cold history recovery view', () => { }) }) +describe('Remote Agent and Session lookup policy', () => { + it('deduplicates a cold resume across Agent and Session parameters', async () => { + const ctx = new Context() + await ctx.plugin(TypertRegistry) + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) + const sessionId = sid('session-remote-cold') + const meta = header(sessionId, 1000) + const inspect = vi.fn(() => Promise.resolve({ meta, events: [] as SessionEvent[] })) + ctx.provide('sessionPersistence', { + list: () => Promise.resolve([meta]), + inspect, + locate: () => undefined, + } as never) + const resumedSession = { id: sessionId, header: meta, events: [] } as unknown as import('@deepseek-ai/dsh-session').Session + const resumedAgent = { id: sessionId, session: resumedSession, status: 'idle', ctx } as Agent + const release = Promise.withResolvers() + const resume = vi.spyOn(ctx.agents, 'resume').mockImplementation(async () => { + await release.promise + return { agent: resumedAgent, dispose: () => Promise.resolve() } + }) + const defaultAgentLookup = ctx.typert.lookups.get('agent') + const defaultSessionLookup = ctx.typert.lookups.get('session') + createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + await vi.waitFor(() => { + expect(ctx.typert.lookups.get('agent')).not.toBe(defaultAgentLookup) + expect(ctx.typert.lookups.get('session')).not.toBe(defaultSessionLookup) + }) + const agentLookup = ctx.typert.lookups.get('agent') + const sessionLookup = ctx.typert.lookups.get('session') + if (agentLookup === undefined || sessionLookup === undefined) throw new Error('core lookup providers were not mounted') + + const resolvedAgent = Promise.resolve(agentLookup.resolve(sessionId)) + const resolvedSession = Promise.resolve(sessionLookup.resolve(sessionId)) + await vi.waitFor(() => { expect(resume).toHaveBeenCalledOnce() }) + release.resolve(undefined) + + await expect(resolvedAgent).resolves.toBe(resumedAgent) + await expect(resolvedSession).resolves.toBe(resumedSession) + expect(inspect).toHaveBeenCalledOnce() + }) + + it('preserves the subagent ownership fence for cold and live Remote lookups', async () => { + const ctx = new Context() + await ctx.plugin(TypertRegistry) + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) + const coldId = sid('session-remote-cold-child') + const coldMeta = header(coldId, 1000, { + parentSession: sid('session-parent'), + origin: 'subagent', + }) + const inspect = vi.fn(() => Promise.resolve({ meta: coldMeta, events: [] as SessionEvent[] })) + ctx.provide('sessionPersistence', { + list: () => Promise.resolve([coldMeta]), + inspect, + locate: () => undefined, + } as never) + const liveSession = ctx.sessions.create(sid('session-remote-live-child'), { + meta: { cwd: '/proj', parentSession: sid('session-parent'), origin: 'subagent' }, + }) + const liveAgent = { id: liveSession.id, session: liveSession, status: 'idle', ctx } as Agent + ctx.agents.register(liveAgent) + const resume = vi.spyOn(ctx.agents, 'resume') + const defaultAgentLookup = ctx.typert.lookups.get('agent') + const defaultSessionLookup = ctx.typert.lookups.get('session') + createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + await vi.waitFor(() => { + expect(ctx.typert.lookups.get('agent')).not.toBe(defaultAgentLookup) + expect(ctx.typert.lookups.get('session')).not.toBe(defaultSessionLookup) + }) + const agentLookup = ctx.typert.lookups.get('agent') + const sessionLookup = ctx.typert.lookups.get('session') + if (agentLookup === undefined || sessionLookup === undefined) throw new Error('core lookup providers were not mounted') + const ownershipFailure = { + failure: { + code: 'agent-busy', + details: { reason: 'use subagent delivery for this child session' }, + }, + } + + const coldFailure = Promise.resolve(agentLookup.resolve(coldId)) + const liveFailure = Promise.resolve(sessionLookup.resolve(liveSession.id)) + await expect(coldFailure).rejects.toBeInstanceOf(TypeRTLookupFailure) + await expect(coldFailure).rejects.toMatchObject(ownershipFailure) + await expect(liveFailure).rejects.toBeInstanceOf(TypeRTLookupFailure) + await expect(liveFailure).rejects.toMatchObject(ownershipFailure) + expect(resume).not.toHaveBeenCalled() + expect(inspect).toHaveBeenCalledOnce() + }) +}) + describe('subagent ownership fence', () => { it('reads a cold child without an Agent and rejects generic resume or adoption', async () => { const ctx = new Context() diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index c648d7a30d..23c170f4fd 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -38,6 +38,12 @@ { "path": "../../core/tools" }, + { + "path": "../../typert/type-meta" + }, + { + "path": "../../typert/registry" + }, { "path": "../../session-persistence/session-persistence" }, diff --git a/packages/typert/registry/README.i18n.yaml b/packages/typert/registry/README.i18n.yaml index b8c97637c9..a6180c6bfc 100644 --- a/packages/typert/registry/README.i18n.yaml +++ b/packages/typert/registry/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/typert/registry/README.md -README.md: 83c03ab284abf2b7cab4dd1ee70d7e855184a1e0 -README.zh.md: db2140e51d85be53bbf6eb4d1dd86ec38ceefc58 +README.md: dae8c3ed124fd6e2d61eb47964e2c07dda762b48 +README.zh.md: aea74b3753feccd88ee132363dc60ade02161498 diff --git a/packages/typert/registry/README.md b/packages/typert/registry/README.md index 83c03ab284..dae8c3ed12 100644 --- a/packages/typert/registry/README.md +++ b/packages/typert/registry/README.md @@ -9,6 +9,7 @@ Package reflection is keyed by `#`. Schemas are keyed by `>() + private readonly resolvers = new Map>() private readonly definitions = new Map() private readonly changes: ChangeSource @@ -229,13 +231,61 @@ class LookupStore { TypeRTLookupWire >, ) => this.register(ctx, key, provider), - get: key => this.providers.get(key)?.provider, + configure: >( + key: K, + resolver: TypeRTLookupResolver< + TypeRTLookupHost, + TypeRTLookupWire + >, + ) => this.configure(ctx, key, resolver), + get: key => this.get(key), definitions: () => [...this.definitions.values()], keys: () => [...this.providers.keys()], subscribe: listener => this.changes.subscribe(ctx, listener), } } + private get(key: string): TypeRTLookupProvider | undefined { + const provider = this.providers.get(key)?.provider + if (provider === undefined) return undefined + const resolver = this.resolvers.get(key)?.provider + if (resolver === undefined) return provider + return { + parameter: provider.parameter, + wire: provider.wire, + hostTypeSymbol: provider.hostTypeSymbol, + wireTypeSymbol: provider.wireTypeSymbol, + resolve: id => resolver.resolve(id), + } + } + + private configure( + ctx: Context, + key: string, + resolver: TypeRTLookupResolver, + ): TypeRTDisposer { + validateSegment('lookup key', key) + if (this.resolvers.has(key)) throw new Error(`typert: lookup "${key}" resolver is already configured`) + const owner = {} + // The map erases each merge-declared Wire type; restore it only at the + // typed configure() boundary so strict function variance remains sound. + const entry: ProviderEntry = { + provider: { resolve: async id => resolver(id as Wire) }, + owner, + } + const { resolvers, changes } = this + return ctx.effect(function* () { + resolvers.set(key, entry) + changes.emit({ kind: 'lookup', key }) + yield () => { + /* v8 ignore next -- duplicate configuration is rejected, so this effect remains the key's unique owner. */ + if (resolvers.get(key) !== entry) return + resolvers.delete(key) + changes.emit({ kind: 'lookup', key }) + } + }, `typert.lookups.configure(${JSON.stringify(key)})`) + } + private register(ctx: Context, key: string, provider: TypeRTLookupProvider): TypeRTDisposer { validateSegment('lookup key', key) validateSegment('lookup parameter', provider.parameter) @@ -271,6 +321,10 @@ class LookupStore { } } +interface LookupResolverEntry { + resolve(id: unknown): Promise +} + function lookupDefinitionEquals(left: TypeRTLookupDefinition, right: TypeRTLookupDefinition): boolean { return left.parameter === right.parameter && left.wire === right.wire diff --git a/packages/typert/registry/tests/typert.spec.ts b/packages/typert/registry/tests/typert.spec.ts index 29654babf7..087cf00fc4 100644 --- a/packages/typert/registry/tests/typert.spec.ts +++ b/packages/typert/registry/tests/typert.spec.ts @@ -355,6 +355,40 @@ describe('TypertRegistry', () => { expect(ctx.typert.contexts.getClient('registryFixture')).toBeUndefined() }) + it('configures an asynchronous lookup resolver independently of provider load order', async () => { + const ctx = await makeCtx() + const fallback = { id: 'fallback' } + const configured = { id: 'configured' } + const disposeResolver = ctx.typert.lookups.configure('fixture', async id => + id === configured.id ? configured : undefined) + + expect(ctx.typert.lookups.get('fixture')).toBeUndefined() + const disposeProvider = ctx.typert.lookups.register('fixture', { + parameter: 'agent', + wire: 'agentId', + hostTypeSymbol: '@fixture/agent#Agent', + wireTypeSymbol: '@fixture/session#SessionId', + resolve: id => id === fallback.id ? fallback : undefined, + }) + await expect(ctx.typert.lookups.get('fixture')?.resolve('configured')).resolves.toBe(configured) + expect(() => ctx.typert.lookups.configure('fixture', () => undefined)).toThrow('already configured') + + await disposeProvider() + expect(ctx.typert.lookups.get('fixture')).toBeUndefined() + const disposeReloadedProvider = ctx.typert.lookups.register('fixture', { + parameter: 'agent', + wire: 'agentId', + hostTypeSymbol: '@fixture/agent#Agent', + wireTypeSymbol: '@fixture/session#SessionId', + resolve: id => id === fallback.id ? fallback : undefined, + }) + await expect(ctx.typert.lookups.get('fixture')?.resolve('configured')).resolves.toBe(configured) + + await disposeResolver() + expect(ctx.typert.lookups.get('fixture')?.resolve('fallback')).toBe(fallback) + await disposeReloadedProvider() + }) + it('publishes provider changes, rejects duplicate providers, and disposes subscriptions', async () => { const ctx = await makeCtx() const changes: string[] = [] diff --git a/packages/typert/type-meta/README.i18n.yaml b/packages/typert/type-meta/README.i18n.yaml index a3e0643ace..510b8d3854 100644 --- a/packages/typert/type-meta/README.i18n.yaml +++ b/packages/typert/type-meta/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/typert/type-meta/README.md -README.md: 245df305efcf711486b2d3f32e40a8b415f2682e -README.zh.md: 592aa5d027a52a7a277a90ba5d51f19101f055f6 +README.md: b394c843409e840b75bbb08b128614379e528001 +README.zh.md: 5bd9bb18289a0320e0603d8b373e60d7f1e3c7e5 diff --git a/packages/typert/type-meta/README.md b/packages/typert/type-meta/README.md index 245df305ef..b394c84340 100644 --- a/packages/typert/type-meta/README.md +++ b/packages/typert/type-meta/README.md @@ -20,7 +20,7 @@ Decorator initializers retain markers in a module-private `WeakMap` keyed by the Business packages extend `TypeRTLookupMap` and `TypeRTContextMap` to associate Host objects or scoped Contexts with their wire identities. Generated artifacts extend `TypeRTRemoteMap`, `TypeRTRemoteContextMap`, and `TypeRTRemoteNamespaceMap` so Client imports expose only selected Remote methods. `InvocationDescriptor` is the shared runtime form consumed by the registry, Gateway, and Client API. -Lookup and Context packages own both sides of their contract: declaration merging supplies the static association, while runtime providers register identity resolution with `ctx.typert`. Strict codecs carry generated schemas; `src-json` codecs identify the weaker source-launch path. +Lookup and Context packages own both sides of their contract: declaration merging supplies the static association, while runtime providers register identity resolution with `ctx.typert`. A lookup provider supplies the stable declaration and default resolver, while Host composition may separately configure a synchronous or asynchronous resolver; policy rejections may use `TypeRTLookupFailure` to carry a failure value owned by the boundary adapter. Strict codecs carry generated schemas; `src-json` codecs identify the weaker source-launch path. ## Model Experience diff --git a/packages/typert/type-meta/README.zh.md b/packages/typert/type-meta/README.zh.md index 592aa5d027..5bd9bb1828 100644 --- a/packages/typert/type-meta/README.zh.md +++ b/packages/typert/type-meta/README.zh.md @@ -20,7 +20,7 @@ Host 方法通过将 `signal: AbortSignal` 声明为最后一个参数来启用 业务包扩展 `TypeRTLookupMap` 和 `TypeRTContextMap`,以关联 Host 对象或作用域 Context 与其协议身份。生成的产物扩展 `TypeRTRemoteMap`、`TypeRTRemoteContextMap` 和 `TypeRTRemoteNamespaceMap`,使 Client 导入后仅暴露选定的 Remote 方法。`InvocationDescriptor` 是供注册表、Gateway 和 Client API 使用的共享运行时形式。 -查找包与 Context 包同时负责其契约的两侧:声明合并提供静态关联,运行时提供方则向 `ctx.typert` 注册身份解析。严格编解码器携带生成的 schema;`src-json` 编解码器标识约束更弱的源码启动路径。 +查找包与 Context 包同时负责其契约的两侧:声明合并提供静态关联,运行时提供方则向 `ctx.typert` 注册身份解析。lookup provider 提供稳定声明与默认 resolver,Host 组合可以另行配置同步或异步 resolver;策略拒绝可用 `TypeRTLookupFailure` 携带由边界适配器拥有的失败值。严格编解码器携带生成的 schema;`src-json` 编解码器标识约束更弱的源码启动路径。 ## 模型体验 diff --git a/packages/typert/type-meta/src/index.ts b/packages/typert/type-meta/src/index.ts index 3d782dbb77..7ded29fa4a 100644 --- a/packages/typert/type-meta/src/index.ts +++ b/packages/typert/type-meta/src/index.ts @@ -18,6 +18,25 @@ export function isTypeRTRemoteSegment(value: string): boolean { return value !== '.' && value !== '..' && TYPERT_REMOTE_SEGMENT_PATTERN.test(value) } +/** + * A lookup policy rejection whose typed payload belongs to the active boundary adapter. + * Gateway adapters preserve this payload instead of collapsing it into an infrastructure failure. + */ +export class TypeRTLookupFailure extends Error { + /** Adapter-owned failure returned to the caller. */ + readonly failure: Failure + + /** + * Wrap one adapter failure without exposing the rejected identity. + * @param failure - typed failure owned by the active boundary adapter. + */ + constructor(failure: Failure) { + super('TypeRT lookup policy rejected the requested identity') + this.name = 'TypeRTLookupFailure' + this.failure = failure + } +} + export type { InvocationDescriptor, InvocationParameterDescriptor, @@ -36,6 +55,7 @@ export type { TypeRTLookupHost, TypeRTLookupMap, TypeRTLookupProvider, + TypeRTLookupResolver, TypeRTLookupRegistry, TypeRTLookupWire, TypeRTRemoteContextApi, diff --git a/packages/typert/type-meta/src/types.ts b/packages/typert/type-meta/src/types.ts index 6de5c7f823..7831c08e37 100644 --- a/packages/typert/type-meta/src/types.ts +++ b/packages/typert/type-meta/src/types.ts @@ -176,7 +176,16 @@ export interface TypeRTRemoteContribution { readonly descriptors: readonly InvocationDescriptor[] } -/** Runtime resolver for one declared Host object lookup. */ +/** + * Resolve one validated wire identity, synchronously or asynchronously. + * @param id - validated wire identity. + * @returns the Host object, or `undefined` when unavailable. + */ +export type TypeRTLookupResolver = ( + id: Wire, +) => Host | undefined | Promise + +/** Runtime provider for one declared Host object lookup. */ export interface TypeRTLookupProvider { /** Source parameter name recognized by the SRC weak parser. */ readonly parameter: string @@ -187,11 +196,11 @@ export interface TypeRTLookupProvider { /** Canonical wire type symbol used by strict generation. */ readonly wireTypeSymbol: string /** - * Resolve a wire identity to the current live Host object. + * Resolve a wire identity through the provider's default policy. * @param id - validated wire identity. - * @returns the live object, or `undefined` when it is unavailable. + * @returns the object, `undefined` when unavailable, or either asynchronously. */ - resolve(id: Wire): Host | undefined + resolve(id: Wire): Host | undefined | Promise } /** Stable wire declaration retained after a lookup provider unloads. */ @@ -304,6 +313,20 @@ export interface TypeRTLookupRegistry { TypeRTLookupWire >, ): TypeRTDisposer + /** + * Replace one provider's default resolution policy while this contribution is active. + * Configuration may precede provider registration; without a live provider, `get()` remains unavailable. + * @param key - lookup key whose wire declaration remains provider-owned. + * @param resolver - composition-owned resolver used by every lookup of this key. + * @returns disposer restoring the provider's default resolver. + */ + configure>( + key: K, + resolver: TypeRTLookupResolver< + TypeRTLookupHost, + TypeRTLookupWire + >, + ): TypeRTDisposer /** * Look up one provider by runtime key. * @param key - descriptor lookup key. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 79d38a43cd..e0adfb22c1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3838,6 +3838,12 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../../typert/type-meta + '@deepseek-ai/dsh-typert-registry': + specifier: workspace:^ + version: link:../../typert/registry '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../../ui/user-approval From bb61dc13f221fb9052a52a0c7e337fbd8e4c5898 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:48:29 +0800 Subject: [PATCH 084/104] refactor(api): colocate gateway and remote assembly --- ...08-02-typert-remote-method-calls.i18n.yaml | 4 +- .../2026-08-02-typert-remote-method-calls.md | 24 ++- ...026-08-02-typert-remote-method-calls.zh.md | 24 ++- AGENTS.md | 1 + apps/cli/composition.md | 4 +- apps/web/tests/assembled-boot.ts | 6 +- docs/api-gateway.i18n.yaml | 4 +- docs/api-gateway.md | 21 +- docs/api-gateway.zh.md | 21 +- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/capability-seams.md | 4 +- docs/config-catalog.md | 4 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/typert.i18n.yaml | 4 +- docs/core-data-structures/typert.md | 8 +- docs/core-data-structures/typert.zh.md | 8 +- docs/development.i18n.yaml | 4 +- docs/development.md | 2 +- docs/development.zh.md | 2 +- docs/event-producer-consumer.md | 2 +- docs/module-graph.md | 39 +++- knip.json | 2 +- packages/README.i18n.yaml | 4 +- packages/README.md | 1 + packages/README.zh.md | 1 + .../{client/remotes => api}/README.i18n.yaml | 6 +- packages/api/README.md | 17 ++ packages/api/README.zh.md | 17 ++ .../gateway}/README.i18n.yaml | 6 +- .../api-gateway => api/gateway}/README.md | 6 +- .../api-gateway => api/gateway}/README.zh.md | 6 +- .../api-gateway => api/gateway}/package.json | 4 +- .../gateway}/src/client/index.ts | 16 +- .../api-gateway => api/gateway}/src/index.ts | 2 +- .../gateway}/src/invariant.ts | 8 +- .../api-gateway => api/gateway}/src/types.ts | 2 +- .../gateway}/tests/client.spec.ts | 0 .../gateway}/tests/gateway.spec.ts | 2 +- .../api-gateway => api/gateway}/tsconfig.json | 0 packages/api/gateway/tsdown.config.ts | 3 + packages/api/remotes/README.i18n.yaml | 6 + packages/api/remotes/README.md | 25 +++ packages/api/remotes/README.zh.md | 25 +++ packages/{client => api}/remotes/package.json | 19 +- packages/api/remotes/src/agent-lookup.ts | 193 ++++++++++++++++++ .../remotes/src/client/index.ts | 11 +- packages/api/remotes/src/index.ts | 18 ++ .../{client => api}/remotes/src/invariant.ts | 8 +- .../remotes/tests/built-lib.e2e.ts | 16 +- .../{client => api}/remotes/tsconfig.json | 14 +- packages/api/remotes/tsdown.config.ts | 3 + packages/bundle/base/cordis.patch.yml | 2 +- packages/bundle/base/package.json | 2 +- packages/bundle/web-app/cordis.patch.yml | 4 +- packages/bundle/web-app/package.json | 2 +- packages/client/remotes/README.md | 22 -- packages/client/remotes/README.zh.md | 22 -- packages/client/remotes/src/index.ts | 4 - packages/client/remotes/tsdown.config.ts | 3 - packages/client/runtime/package.json | 6 +- packages/client/runtime/src/client/index.ts | 2 +- packages/client/runtime/tsconfig.json | 2 +- packages/client/ui-goal/package.json | 6 +- packages/client/ui-goal/src/client/index.ts | 2 +- packages/client/ui-goal/tsconfig.json | 2 +- packages/host/api-gateway/tsdown.config.ts | 3 - packages/host/apiproxy/package.json | 5 +- packages/host/apiproxy/src/api-proxy.ts | 161 ++------------- packages/host/apiproxy/tsconfig.json | 9 +- packages/typert/type-meta/src/index.ts | 1 + packages/typert/type-meta/src/types.ts | 10 + pnpm-lock.yaml | 130 ++++++------ scripts/gen-cordis-catalog.ts | 2 +- scripts/run-gates.ts | 2 +- scripts/type-equiv.manifest.json | 10 +- .../verify-package-readme-model-experience.ts | 4 +- tsconfig.base.json | 12 +- tsconfig.client.json | 4 +- tsconfig.host.json | 2 +- vitest.config.ts | 4 +- 82 files changed, 645 insertions(+), 432 deletions(-) rename packages/{client/remotes => api}/README.i18n.yaml (56%) create mode 100644 packages/api/README.md create mode 100644 packages/api/README.zh.md rename packages/{host/api-gateway => api/gateway}/README.i18n.yaml (56%) rename packages/{host/api-gateway => api/gateway}/README.md (86%) rename packages/{host/api-gateway => api/gateway}/README.zh.md (86%) rename packages/{host/api-gateway => api/gateway}/package.json (92%) rename packages/{host/api-gateway => api/gateway}/src/client/index.ts (96%) rename packages/{host/api-gateway => api/gateway}/src/index.ts (99%) rename packages/{host/api-gateway => api/gateway}/src/invariant.ts (77%) rename packages/{host/api-gateway => api/gateway}/src/types.ts (97%) rename packages/{host/api-gateway => api/gateway}/tests/client.spec.ts (100%) rename packages/{host/api-gateway => api/gateway}/tests/gateway.spec.ts (99%) rename packages/{host/api-gateway => api/gateway}/tsconfig.json (100%) create mode 100644 packages/api/gateway/tsdown.config.ts create mode 100644 packages/api/remotes/README.i18n.yaml create mode 100644 packages/api/remotes/README.md create mode 100644 packages/api/remotes/README.zh.md rename packages/{client => api}/remotes/package.json (64%) create mode 100644 packages/api/remotes/src/agent-lookup.ts rename packages/{client => api}/remotes/src/client/index.ts (64%) create mode 100644 packages/api/remotes/src/index.ts rename packages/{client => api}/remotes/src/invariant.ts (70%) rename packages/{client => api}/remotes/tests/built-lib.e2e.ts (95%) rename packages/{client => api}/remotes/tsconfig.json (63%) create mode 100644 packages/api/remotes/tsdown.config.ts delete mode 100644 packages/client/remotes/README.md delete mode 100644 packages/client/remotes/README.zh.md delete mode 100644 packages/client/remotes/src/index.ts delete mode 100644 packages/client/remotes/tsdown.config.ts delete mode 100644 packages/host/api-gateway/tsdown.config.ts diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index c76dabca3c..9ba0cf8dc1 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: d91f6f173c1b56efcd21d3136392837e61f54aae -2026-08-02-typert-remote-method-calls.zh.md: 0c548522d1137f0e0002a740d12ca0b796da5e39 +2026-08-02-typert-remote-method-calls.md: c4f3a5b94bf25b4581b9430cfcb4f02f707e0749 +2026-08-02-typert-remote-method-calls.zh.md: e11d8ebe42d44cc9805e942a31f13f7ae847815a diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index d91f6f173c..c4f3a5b94b 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -20,7 +20,9 @@ A business Service extends `GatewayService` and declares callable methods with ` The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. The `.d.ts` exposes only methods marked with a Remote decorator and refers to the business package's single public type symbols. The `.d.ts.map` navigates consumer API methods back to their Host business method implementations. The `.js` carries endpoint, parameter, Context, and Zod information for the same contract. At the assembly layer, the Browser Client mounts the required Remote JS contributions onto the Client API Service. The projection and API abstraction remain platform-independent so that a future TUI can reuse them. -`@deepseek-ai/dsh-host-api-gateway`, located at `packages/host/api-gateway`, provides two symmetric faces: its default entry provides Host `ctx.typertGateway`, while its `/client` entry provides consumer-side `ctx.api`. Each side consumes a locally generated `InvocationDescriptor` from the same model; descriptors are not sent over the wire. The Remote data protocol runs over Connection's shared `/api` RPC channel. The business calling interface does not change when Connection migrates from HTTP to WebSocket. +`@deepseek-ai/dsh-api-gateway`, located at `packages/api/gateway`, provides two symmetric faces: its default entry provides Host `ctx.typertGateway`, while its `/client` entry provides consumer-side `ctx.api`. Each side consumes a locally generated `InvocationDescriptor` from the same model; descriptors are not sent over the wire. The Remote data protocol runs over Connection's shared `/api` RPC channel. The business calling interface does not change when Connection migrates from HTTP to WebSocket. + +`@deepseek-ai/dsh-api-remotes`, located at `packages/api/remotes`, is the BFF layer above the Gateway. Its Host entry owns Agent/Session identity resolution and TypeRT lookup configuration; its `/client` entry selects the generated Remote contributions exposed by the application. The Client entry consumes the shared `TypeRTClientApi` contract through Cordis rather than importing the concrete Gateway implementation. ## Components and Cordis services @@ -29,10 +31,10 @@ The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. T | `@deepseek-ai/dsh-type-meta` | Declares only the minimal `ctx.typert` protocol | `GatewayService`, decorators, binding fallback, descriptors, lookup/Context, and the Remote map; no dependency on the compiler, Zod, Connection, or Browser | | TypeRT registry | `ctx.typert` | Separately stores reflection for the current environment, imported Remote contributions, lookup providers, and Context providers | | TypeRT generator/loader | No new business service | Generates three kinds of `lib` artifacts from the Host/Client Programs and registers the current environment's artifacts with `ctx.typert` | -| Host API Gateway's Host face | `ctx.typertGateway` | Associates Host definitions with live Services, decodes parameters, resolves receivers, invokes methods, and encodes results | +| API Gateway's Host face | `ctx.typertGateway` | Associates Host definitions with live Services, decodes parameters, resolves receivers, invokes methods, and encodes results | | Connection | `ctx.connection` | Exclusively owns the HTTP Server/future WebSocket, the shared `/api` route, RPC envelope, rpcId, serialization, trust, error transport, TypeRT interception, and legacy API Proxy fallback | -| Host API Gateway's Client face | `ctx.api` | Mounts Remote contributions, materializes root and scoped APIs, and delegates canonical calls to `ctx.connection.rpc` | -| Client Remotes | No new service | Serves as the only Remote facade for Client business code, selecting and mounting `/remote` contributions while exposing the Gateway Client face and the selected API declarations | +| API Gateway's Client face | `ctx.api` | Mounts Remote contributions, materializes root and scoped APIs, and delegates canonical calls to `ctx.connection.rpc` | +| API Remotes | No new service | Owns Host Agent/Session lookup policy and serves as the only Client business facade, selecting and mounting `/remote` contributions while exposing the selected API declarations | | Agent/Session owning packages | Existing domain services | Provide both static interface merges and runtime lookup/Context providers | | Business packages such as Goal | Existing business Services | Declare only bindings, Remote methods, and canonical DTOs, and export the generated `/remote` subpath | @@ -162,7 +164,7 @@ Every registration returns a disposer owned by the caller's Cordis fiber. Client The lookup registry retains the stable wire declaration after its live resolver unloads. SRC parsing continues to classify the parameter as a lookup, while invocation fails with `lookup-unavailable`; it never reclassifies the incoming ID as an ordinary JSON business object. Re-registering the same key with different parameter, wire, or canonical type symbols fails for the lifetime of that TypeRT Service. -Business-object packages own stable declarations and default resolvers through `register()`; Host composition supplies an effect-scoped asynchronous policy for the same key through `configure()`. Configuration may precede provider registration, but does not by itself make a lookup available without a live provider; unloading the configuration restores the provider's default resolver. The standard Web Host's API Proxy configures the same `agentFor()` for `agent` and `session`: live Agents are reused, ordinary cold sessions are resumed automatically, concurrent resumes are deduplicated by Session ID, and the subagent ownership fence returns the existing `agent-busy`. The `session` resolver returns the resolved Agent's Session, so the two parameter kinds do not create separate resume lifecycles. +Business-object packages own stable declarations and default resolvers through `register()`; Host composition supplies an effect-scoped asynchronous policy for the same key through `configure()`. Configuration may precede provider registration, but does not by itself make a lookup available without a live provider; unloading the configuration restores the provider's default resolver. API Remotes creates the shared `agentFor()` resolver for `agent` and `session`: live Agents are reused, ordinary cold sessions are resumed automatically, concurrent resumes are deduplicated by Session ID, and the subagent ownership fence returns the existing `agent-busy`. The standard Web API Proxy supplies its Agent defaults and scope setup and consumes that resolver for legacy methods. The `session` resolver returns the resolved Agent's Session, so the two parameter kinds do not create separate resume lifecycles. The registry's Host root entry has the complete `TypeRTService` interface merge. The registry implementation shared by Host and Client lives in a separate module without environment declarations. The registry's `/client` entry imports only that shared implementation and does not pass through the Host root entry, so it cannot bring Host Cordis declarations into the Client Program. @@ -295,7 +297,7 @@ TypeRT.local 当前环境自己的反射模型 TypeRT.remotes 已导入的 Remote contribution ``` -`@deepseek-ai/dsh-client-remotes/client` centrally loads the required Remote contributions: +`@deepseek-ai/dsh-api-remotes/client` centrally loads the required Remote contributions: ```text import goalsRemote from '@deepseek-ai/dsh-goal/remote' @@ -305,7 +307,7 @@ ctx.api.mount(goalsRemote) ctx.api.mount(sessionsRemote) ``` -Client business packages depend only on `@deepseek-ai/dsh-client-remotes/client`, not directly on the Host API Gateway or the runtime entry of each business `/remote`. Client Remotes itself depends on the Gateway Client face and re-exports declarations so the selected Remote map reaches business compilation. Adding or removing a complete Client capability changes only this assembly point. +Client business packages depend only on `@deepseek-ai/dsh-api-remotes/client`, not directly on the API Gateway or the runtime entry of each business `/remote`. API Remotes consumes the shared `TypeRTClientApi` contract and Cordis `ctx.api` service, then re-exports declarations so the selected Remote map reaches business compilation. Adding or removing a complete Client capability changes only this assembly point. `ctx.api.mount()` registers a contribution with `TypeRT.remotes`, and its disposer is owned by the Cordis fiber that called the method. Duplicate endpoints, conflicting invocation modes for the same namespace and method, or conflicts between a descriptor and an existing type identity fail immediately. @@ -449,11 +451,11 @@ The Gateway registers only its ownership matcher and RPC handler with Connection - `@deepseek-ai/dsh-type-meta`: lightweight protocols for decorators, bindings, lookup, Remote Context, and descriptors. - TypeRT generator: analyzes Host/Client Programs, generates local faces and Remote consumer projections, and emits canonical symbol/Zod information. - TypeRT runtime: separately stores the current environment's local reflection and imported Remote contributions. -- `@deepseek-ai/dsh-host-api-gateway`: its default entry associates Host definitions with Services, claims Remote endpoints, performs lookup, resolves Context receivers, invokes methods, encodes results, and registers an `/api` interceptor with Connection; its `/client` entry mounts Remote contributions, creates strict API methods, and delegates calls to `ctx.connection.rpc`. The entries share the Remote protocol but do not import each other's Cordis interface merges. -- `@deepseek-ai/dsh-client-remotes`: the only Remote facade depended on by Client business code; directly depends on the Gateway Client face, selects `/remote` contributions, and exposes the merged API types to business packages. +- `@deepseek-ai/dsh-api-gateway`: its default entry associates Host definitions with Services, claims Remote endpoints, performs lookup, resolves Context receivers, invokes methods, encodes results, and registers an `/api` interceptor with Connection; its `/client` entry mounts Remote contributions, creates strict API methods, and delegates calls to `ctx.connection.rpc`. The entries share the Remote protocol but do not import each other's Cordis interface merges. +- `@deepseek-ai/dsh-api-remotes`: the BFF layer; owns the Host Agent/Session resolver, selects Client `/remote` contributions, and exposes the merged API types to business packages through the shared `TypeRTClientApi` contract. - Connection: owns the single HTTP Server/future WebSocket carrier, shared `/api` route and composite FetchHandler, API Proxy fallback, RPC envelope, rpcId, serialization, trust, and error transport. - Business-object packages such as Agent/Session: own lookup, Context providers, canonical ID types, and public type-only entries. -- API Proxy Host composition: configures cold resume, concurrent deduplication, and subagent ownership policy for `agent`/`session` lookups through the existing `agentFor()`. +- API Proxy Host composition: supplies Web Agent defaults and scope setup to API Remotes and consumes the same `agentFor()` for legacy methods. - Business Service packages: declare bindings, Remote methods, and their request/result types, and export the generated `/remote` subpath. ## Shipped scope and deferred work @@ -462,6 +464,8 @@ The shipped vertical path is `@deepseek-ai/dsh-goal/remote → Browser Client AP Connection supplies the shared-channel interceptor and current HTTP carrier mapping. WebSocket migration, the TUI runtime and carrier, TUI Agent Scope wiring, Permission/Approval state machines, Session event streams, call authorization, retries, idempotency, and cross-version protocol compatibility remain outside this decision. +The package topology is `api/remotes → api/gateway → client/connection → host/webserver`. Connection and WebServer retain their existing paths in this change; moving them later to `api/connection` and `api/webserver` changes package placement rather than these service boundaries. The legacy API Proxy likewise remains under `host/apiproxy` as the fallback for methods not yet migrated to Remote. + ## Alternatives considered **Continue using the central API Proxy package.** This would require business methods, Host routes, and Client interfaces to be declared repeatedly in several locations. It would also keep direct calls, stateful interactions, and event streams tied to the same lifecycle, so this alternative is rejected. diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index 0c548522d1..e11d8ebe42 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -20,7 +20,9 @@ Host 与 Browser Client 使用独立的 TypeScript Program,因为两边会以 Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只暴露被 Remote decorator 标记的方法,并引用业务包唯一的公共类型符号;`.d.ts.map` 把消费端 API 方法导航回 Host 业务方法实现;`.js` 携带同一契约的 endpoint、参数、Context 和 Zod 信息。Browser Client 在 assembly 层把需要的 Remote JS 贡献集中挂到 Client API Service;该投影和 API 抽象保持平台无关,以便未来 TUI 复用。 -`@deepseek-ai/dsh-host-api-gateway` 在 `packages/host/api-gateway` 内提供对称的两个 face:默认入口提供 Host `ctx.typertGateway`,`/client` 入口提供消费端 `ctx.api`。两边各自在本地消费由同一模型生成的 `InvocationDescriptor`,descriptor 不通过 wire 发送。Remote 数据协议运行在 Connection 共享的 `/api` RPC channel 上;业务调用界面不随 Connection 从 HTTP 迁移到 WebSocket 而改变。 +`@deepseek-ai/dsh-api-gateway` 位于 `packages/api/gateway`,提供对称的两个 face:默认入口提供 Host `ctx.typertGateway`,`/client` 入口提供消费端 `ctx.api`。两边各自在本地消费由同一模型生成的 `InvocationDescriptor`,descriptor 不通过 wire 发送。Remote 数据协议运行在 Connection 共享的 `/api` RPC channel 上;业务调用界面不随 Connection 从 HTTP 迁移到 WebSocket 而改变。 + +`@deepseek-ai/dsh-api-remotes` 位于 `packages/api/remotes`,是 Gateway 上层的 BFF 层。其 Host 入口负责 Agent/Session 身份解析与 TypeRT lookup 配置;`/client` 入口选择应用对外暴露的生成 Remote contribution。Client 入口通过 Cordis 消费共享的 `TypeRTClientApi` 契约,而不导入具体 Gateway 实现。 ## 组件和 Cordis 服务 @@ -29,10 +31,10 @@ Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只 | `@deepseek-ai/dsh-type-meta` | 只声明 `ctx.typert` 的最小协议 | `GatewayService`、decorator、binding 回退、descriptor、lookup/Context 和 Remote map;不依赖 compiler、Zod、Connection 或 Browser | | TypeRT registry | `ctx.typert` | 分开保存当前环境 reflection、导入的 Remote contribution、lookup provider 和 Context provider | | TypeRT generator/loader | 无新增业务服务 | 从 Host/Client Program 生成三类 `lib` 产物,并把当前环境产物注册到 `ctx.typert` | -| Host API Gateway 的 Host face | `ctx.typertGateway` | 关联 Host definition 与活 Service,解码参数、解析 receiver、调用方法和编码结果 | +| API Gateway 的 Host face | `ctx.typertGateway` | 关联 Host definition 与活 Service,解码参数、解析 receiver、调用方法和编码结果 | | Connection | `ctx.connection` | 独占 HTTP Server/未来 WebSocket、共享 `/api` route、RPC envelope、rpcId、序列化、trust、错误传输、TypeRT 拦截和旧 API Proxy 回退 | -| Host API Gateway 的 Client face | `ctx.api` | mount Remote contribution,实体化根 API 和 scoped API,把规范调用交给 `ctx.connection.rpc` | -| Client Remotes | 无新增服务 | 作为 Client 业务的唯一 Remote facade,选择并挂载 `/remote` contribution,同时传递 Gateway Client face 和所选 API 的类型声明 | +| API Gateway 的 Client face | `ctx.api` | mount Remote contribution,实体化根 API 和 scoped API,把规范调用交给 `ctx.connection.rpc` | +| API Remotes | 无新增服务 | 负责 Host Agent/Session lookup 策略,并作为 Client 业务的唯一 facade,选择并挂载 `/remote` contribution,同时暴露所选 API 声明 | | Agent/Session owning 包 | 既有领域服务 | 同时提供静态 interface merge 与运行时 lookup/Context provider | | Goal 等业务包 | 既有业务 Service | 只声明 binding、Remote 方法和唯一 DTO,并导出生成的 `/remote` 子路径 | @@ -162,7 +164,7 @@ ctx.typert.contexts Host Context resolver 与 Client Context binder lookup 注册表会在活 resolver 卸载后保留稳定的 wire 声明。SRC 解析仍会把该参数归类为 lookup,而调用会以 `lookup-unavailable` 失败;系统绝不会把传入的 ID 重新归类为普通 JSON 业务对象。在同一个 TypeRT Service 的生命周期内,以不同参数、wire 或规范类型 symbol 重新注册同一 key 会直接失败。 -业务对象包通过 `register()` 拥有稳定声明和默认 resolver;Host 组合通过 `configure()` 为同一个 key 提供 effect-scoped 异步策略。配置可以先于 provider 注册,但没有活 provider 时不会单独形成可用 lookup;配置卸载后恢复 provider 默认 resolver。标准 Web Host 的 API Proxy 为 `agent` 和 `session` 配置同一套 `agentFor()`:live Agent 直接复用,普通冷会话自动恢复,并发恢复按 Session ID 去重,subagent ownership fence 则返回既有 `agent-busy`。`session` resolver 返回解析所得 Agent 的 Session,因而两种参数不会产生两套恢复生命周期。 +业务对象包通过 `register()` 拥有稳定声明和默认 resolver;Host 组合通过 `configure()` 为同一个 key 提供 effect-scoped 异步策略。配置可以先于 provider 注册,但没有活 provider 时不会单独形成可用 lookup;配置卸载后恢复 provider 默认 resolver。API Remotes 为 `agent` 和 `session` 创建共享的 `agentFor()` resolver:live Agent 直接复用,普通冷会话自动恢复,并发恢复按 Session ID 去重,subagent ownership fence 则返回既有 `agent-busy`。标准 Web API Proxy 提供 Agent 默认值和 scope 设置,并让旧方法使用该 resolver。`session` resolver 返回解析所得 Agent 的 Session,因而两种参数不会产生两套恢复生命周期。 Registry 的 Host 根入口拥有完整 `TypeRTService` interface merge;Host 与 Client 共用的 registry 实现位于无环境声明的独立模块。Registry `/client` 入口只引用该共享实现,不经过 Host 根入口,因此不会把 Host Cordis 声明带入 Client Program。 @@ -295,7 +297,7 @@ TypeRT.local 当前环境自己的反射模型 TypeRT.remotes 已导入的 Remote contribution ``` -`@deepseek-ai/dsh-client-remotes/client` 集中加载需要的 Remote contribution: +`@deepseek-ai/dsh-api-remotes/client` 集中加载需要的 Remote contribution: ```text import goalsRemote from '@deepseek-ai/dsh-goal/remote' @@ -305,7 +307,7 @@ ctx.api.mount(goalsRemote) ctx.api.mount(sessionsRemote) ``` -Client 业务包只引用 `@deepseek-ai/dsh-client-remotes/client`,不直接依赖 Host API Gateway 或各业务 `/remote` 运行时入口。Client Remotes 自己依赖 Gateway Client face,并通过声明 re-export 把所选 Remote map 传给业务编译;新增或移除整套 Client 能力只修改这一处 assembly。 +Client 业务包只引用 `@deepseek-ai/dsh-api-remotes/client`,不直接依赖 API Gateway 或各业务 `/remote` 运行时入口。API Remotes 消费共享的 `TypeRTClientApi` 契约和 Cordis `ctx.api` 服务,再重新导出声明,使所选 Remote map 进入业务编译;新增或移除整套 Client 能力只修改这一处 assembly。 `ctx.api.mount()` 把 contribution 注册到 `TypeRT.remotes`,并由调用该方法的 Cordis fiber 持有 disposer。endpoint 重复、同一 namespace/method 模式冲突或 descriptor 与现有类型身份冲突时直接失败。 @@ -449,11 +451,11 @@ Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 H - `@deepseek-ai/dsh-type-meta`:轻量 decorator、binding、lookup、Remote Context 和 descriptor 协议。 - TypeRT generator:分析 Host/Client Program,生成本地 face 和 Remote 消费端投影,并生成规范 symbol/Zod 信息。 - TypeRT runtime:分别保存当前环境的 local reflection 与导入的 Remote contribution。 -- `@deepseek-ai/dsh-host-api-gateway`:默认入口关联 Host definition 与 Service,认领 Remote endpoint,执行 lookup、Context receiver 解析、调用和结果编码,并向 Connection 注册 `/api` interceptor;`/client` 入口挂载 Remote contribution,创建严格 API 方法,并把调用交给 `ctx.connection.rpc`。两个入口共享 Remote 协议,但不互相导入各自的 Cordis interface merge。 -- `@deepseek-ai/dsh-client-remotes`:Client 业务唯一依赖的 Remote facade;直接依赖 Gateway Client face,选择 `/remote` contributions,并向业务包传递合并后的 API 类型。 +- `@deepseek-ai/dsh-api-gateway`:默认入口关联 Host definition 与 Service,认领 Remote endpoint,执行 lookup、Context receiver 解析、调用和结果编码,并向 Connection 注册 `/api` interceptor;`/client` 入口挂载 Remote contribution,创建严格 API 方法,并把调用交给 `ctx.connection.rpc`。两个入口共享 Remote 协议,但不互相导入各自的 Cordis interface merge。 +- `@deepseek-ai/dsh-api-remotes`:BFF 层;负责 Host Agent/Session resolver,选择 Client `/remote` contribution,并通过共享的 `TypeRTClientApi` 契约向业务包暴露合并后的 API 类型。 - Connection:拥有唯一 HTTP Server/未来 WebSocket carrier、共享 `/api` route 与复合 FetchHandler、API Proxy 回退、RPC envelope、rpcId、序列化、trust 和错误传输。 - Agent/Session 等业务对象包:拥有 lookup、Context provider、唯一 ID 类型和纯类型公共出口。 -- API Proxy Host 组合:用既有 `agentFor()` 配置 `agent`/`session` lookup 的冷恢复、并发去重和 subagent ownership 策略。 +- API Proxy Host 组合:向 API Remotes 提供 Web Agent 默认值和 scope 设置,并让旧方法使用同一个 `agentFor()`。 - 业务 Service 包:声明 binding、Remote 方法及其 request/result 类型,并导出生成的 `/remote` 子路径。 ## 已交付范围与后续工作 @@ -462,6 +464,8 @@ Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 H Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebSocket 迁移、TUI runtime 与 carrier、TUI Agent Scope 接线、Permission/Approval 状态机、Session 事件流、调用授权、重试、幂等及跨版本协议兼容均不属于本决策。 +包拓扑为 `api/remotes → api/gateway → client/connection → host/webserver`。Connection 与 WebServer 在本次变更中保留既有路径;后续将它们移到 `api/connection` 和 `api/webserver` 只会改变包位置,不会改变这些服务边界。旧 API Proxy 同样保留在 `host/apiproxy` 下,作为尚未迁移到 Remote 的方法的回退路径。 + ## Alternatives considered **继续使用中央 API Proxy 包。** 该方案要求业务方法、Host 路由和 Client 接口在多个位置重复声明,也会继续把直接调用、带状态交互和事件流绑在同一生命周期中,因此不采用。 diff --git a/AGENTS.md b/AGENTS.md index 0d27b20df0..c265d3cf32 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,7 @@ DeepSeek Harness SDK is a plugin-based agent harness on vendored Cordis: **every vendor/ Vendored Cordis source — manifest + sync procedure in vendor/README.md packages/ @deepseek-ai/dsh- workspaces at packages/// core/ product API spine: session, system-prompt, tools, agent, agent-loop + api/ Remote BFF assembly and TypeRT RPC gateway typert/ type graph generator, loader, and runtime registry llm/ LLM seam + DeepSeek adapters (direct-fetch + pi-ai design twin) bash/ bash executor seam + local/pwsh impls + model-facing shell tools diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 0246f6163f..45dc52561a 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -22,7 +22,7 @@ flowchart LR cfg --> plugin_dsh_base_typert plugin_dsh_base_typert_loader["typert-loader
    @deepseek-ai/dsh-typert-loader"] cfg --> plugin_dsh_base_typert_loader - plugin_dsh_base_typert_gateway["typert-gateway
    @deepseek-ai/dsh-host-api-gateway"] + plugin_dsh_base_typert_gateway["typert-gateway
    @deepseek-ai/dsh-api-gateway"] cfg --> plugin_dsh_base_typert_gateway plugin_dsh_base_session_title["session-title
    @deepseek-ai/dsh-session-title"] cfg --> plugin_dsh_base_session_title @@ -167,7 +167,7 @@ flowchart LR | `session` | `@deepseek-ai/dsh-session` | | `typert` | `@deepseek-ai/dsh-typert-registry` | | `typert-loader` | `@deepseek-ai/dsh-typert-loader` | -| `typert-gateway` | `@deepseek-ai/dsh-host-api-gateway` | +| `typert-gateway` | `@deepseek-ai/dsh-api-gateway` | | `session-title` | `@deepseek-ai/dsh-session-title` | | `session-title-llm` | `@deepseek-ai/dsh-session-title-first-message-llm` | | `user-interaction` | `@deepseek-ai/dsh-user-interaction` | diff --git a/apps/web/tests/assembled-boot.ts b/apps/web/tests/assembled-boot.ts index ebb2aa513a..729428e47b 100644 --- a/apps/web/tests/assembled-boot.ts +++ b/apps/web/tests/assembled-boot.ts @@ -18,9 +18,9 @@ import { AppWebEntry } from '@deepseek-ai/dsh-client-web' const PLUGINS: readonly (WebBootEntry & { bundlePath: string })[] = [ { id: '@deepseek-ai/dsh-typert-registry', bundlePath: 'packages/typert/registry/lib/client.js', url: '/plugins/typert-registry.js', rev: 'fx', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-connection', bundlePath: 'packages/client/connection/lib/client.js', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true }, - { id: '@deepseek-ai/dsh-host-api-gateway', bundlePath: 'packages/host/api-gateway/lib/client.js', url: '/plugins/api-gateway.js', rev: 'fx', inject: ['@deepseek-ai/dsh-typert-registry', '@deepseek-ai/dsh-client-connection'], immediately: true }, - { id: '@deepseek-ai/dsh-client-remotes', bundlePath: 'packages/client/remotes/lib/client.js', url: '/plugins/client-remotes.js', rev: 'fx', inject: ['@deepseek-ai/dsh-host-api-gateway'], immediately: true }, - { id: '@deepseek-ai/dsh-client-runtime', bundlePath: 'packages/client/runtime/lib/client.js', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-client-remotes', '@deepseek-ai/dsh-typert-registry'], immediately: true }, + { id: '@deepseek-ai/dsh-api-gateway', bundlePath: 'packages/api/gateway/lib/client.js', url: '/plugins/api-gateway.js', rev: 'fx', inject: ['@deepseek-ai/dsh-typert-registry', '@deepseek-ai/dsh-client-connection'], immediately: true }, + { id: '@deepseek-ai/dsh-api-remotes', bundlePath: 'packages/api/remotes/lib/client.js', url: '/plugins/api-remotes.js', rev: 'fx', inject: ['@deepseek-ai/dsh-api-gateway'], immediately: true }, + { id: '@deepseek-ai/dsh-client-runtime', bundlePath: 'packages/client/runtime/lib/client.js', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-api-remotes', '@deepseek-ai/dsh-typert-registry'], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-theme', bundlePath: 'packages/client/ui-theme/lib/client.js', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-locale', bundlePath: 'packages/client/locale/lib/client.js', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-layout', bundlePath: 'packages/client/ui-layout/lib/client.js', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] }, diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index 58891890d3..05038eb8b9 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: 2e0717fd7b0e5b9ca33d650ffad7ac454046f780 -api-gateway.zh.md: 4d1beebf92cae702dac323cdd974b6220091a214 +api-gateway.md: 090758d58306d5ea806567f0de710a1c1f5ed747 +api-gateway.zh.md: 9d7286b6b86918f3bc1e7a6cdd9bdf04447abc57 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 2e0717fd7b..090758d583 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -61,7 +61,7 @@ The Client uses concrete functions on ordinary objects, not a JavaScript Proxy. import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { AgentContext } from '@deepseek-ai/dsh-client-runtime/client' import type { Context } from 'cordis' -import type {} from '@deepseek-ai/dsh-client-remotes/client' +import type {} from '@deepseek-ai/dsh-api-remotes/client' declare const ctx: Context declare const agentCtx: AgentContext @@ -71,9 +71,9 @@ await ctx.api.goals.create(agentId, { objective: 'ship it' }) await agentCtx.goals.create({ objective: 'ship it' }) ``` -Client applications assemble only `@deepseek-ai/dsh-client-remotes`. That package imports the `/remote` subpaths of selected business packages as runtime values, mounts their contributions on `ctx.api`, and re-exports the declaration merges from the same files. Adding a Host Remote package is an explicit choice by the Client composition owner; business components do not need to load the Host API Gateway or the business package's Remote JS separately. +Client applications assemble only `@deepseek-ai/dsh-api-remotes`. That package imports the `/remote` subpaths of selected business packages as runtime values, mounts their contributions on `ctx.api`, and re-exports the declaration merges from the same files. Adding a Host Remote package is an explicit choice by the Client composition owner; business components do not need to load the TypeRT Gateway or the business package's Remote JS separately. -A future TUI can assemble the same React-independent `client-remotes` and `ctx.api` contract, so the Host methods visible to it are likewise limited to the Remote methods selected at generation time. This document does not define or implement the TUI composition. +A future TUI can assemble the same React-independent `api-remotes` and `ctx.api` contract, so the Host methods visible to it are likewise limited to the Remote methods selected at generation time. This document does not define or implement the TUI composition. ## Component responsibilities @@ -82,12 +82,13 @@ A future TUI can assemble the same React-independent `client-remotes` and `ctx.a | Shared | `@deepseek-ai/dsh-type-meta` | Declares decorators, Gateway bindings, merge-extensible protocol maps, invocation descriptors, and provider types; starts no TypeScript analysis and registers no Cordis services | | Build | `@deepseek-ai/dsh-typert-generator` | Strictly analyzes Remote signatures, the type graph, lookups, Contexts, and source locations from the Host `ts.Program`, then generates Host and Host-for-Client artifacts | | Host | `@deepseek-ai/dsh-typert-registry` and Loader | Places generated Host descriptors, schemas, and business-package registrations in `ctx.typert`, and holds lookup and Context providers | -| Host | `@deepseek-ai/dsh-host-api-gateway` | Provides `ctx.typertGateway`, claims Remote endpoints, resolves objects or Contexts, invokes live Cordis services, and validates boundaries | -| Client | `@deepseek-ai/dsh-host-api-gateway/client` | Provides `ctx.api`, mounts generated descriptors as concrete methods, and initiates, validates, and cancels calls through the Connection | -| Client | `@deepseek-ai/dsh-client-remotes/client` | Explicitly selects and mounts the `/remote` contributions allowed by the application and brings the corresponding declaration merges into business code | +| Host | `@deepseek-ai/dsh-api-remotes` | Owns the application Agent/Session identity policy and configures the corresponding TypeRT lookups | +| Host | `@deepseek-ai/dsh-api-gateway` | Provides `ctx.typertGateway`, claims Remote endpoints, resolves objects or Contexts, invokes live Cordis services, and validates boundaries | +| Client | `@deepseek-ai/dsh-api-gateway/client` | Provides `ctx.api`, mounts generated descriptors as concrete methods, and initiates, validates, and cancels calls through the Connection | +| Client | `@deepseek-ai/dsh-api-remotes/client` | Explicitly selects and mounts the `/remote` contributions allowed by the application and brings the corresponding declaration merges into business code | | Both | `@deepseek-ai/dsh-client-connection` | Provides the RPC carrier, request correlation, trust boundary, cancellation, response envelope, and current `/api` HTTP bridge | -The Host API Gateway package owns the Host dispatcher and Client API as peer entries, but the two builds never enter the same `ts.Program`. The Host entry does not import the Client Cordis `Context` merge, and the Client entry does not import the Host Gateway service. +The API Gateway package owns the Host dispatcher and Client API as peer entries, but the two builds never enter the same `ts.Program`. The Host entry does not import the Client Cordis `Context` merge, and the Client entry does not import the Host Gateway service. ## Strict generation pipeline @@ -99,7 +100,7 @@ Each contributing business package writes generated files to its own `lib/` dire |---|---|---| | `typert.host.js` | Host Loader | Runtime reflection for the Host face, strict invocation descriptors, and schema registration values | | `typert.host.d.ts` | Host type system | Generated declarations for the Host face | -| `typert.remote-client.js` | `client-remotes` | A mountable `TypeRTRemoteContribution` containing strict descriptors and runtime codecs | +| `typert.remote-client.js` | `api-remotes` | A mountable `TypeRTRemoteContribution` containing strict descriptors and runtime codecs | | `typert.remote-client.d.ts` | Client type system | Declaration merges for `TypeRTRemoteNamespaceMap` and `TypeRTRemoteContextMap`, plus Client-safe type references | | `typert.remote-client.d.ts.map` | Editor | Maps generated method properties back to Remote method declarations in the Host package | @@ -117,7 +118,7 @@ The Connection performs the unified trust check for `/api` before the HTTP bridg 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 at the boundary 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 standard Web Host's API Proxy configures the same `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. +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. API Remotes 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. 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. @@ -158,4 +159,6 @@ The running Client watcher consumes these generated files when it rebundles; wit 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 remain at `packages/client/connection` and `packages/host/webserver`, with service contracts that permit a later package-only move to `packages/api`. The legacy API Proxy remains at `packages/host/apiproxy` as the fallback for endpoints not yet migrated to Remote. + Lookup policy is currently configured per key, so all `agent` or `session` parameters share the cold-resume behavior. If a Remote endpoint must accept live objects only, an explicit per-parameter or per-endpoint policy must be added later; 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 4d1beebf92..9d7286b6b8 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -61,7 +61,7 @@ Client 使用普通对象上的具体函数,不使用 JavaScript Proxy。直 import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { AgentContext } from '@deepseek-ai/dsh-client-runtime/client' import type { Context } from 'cordis' -import type {} from '@deepseek-ai/dsh-client-remotes/client' +import type {} from '@deepseek-ai/dsh-api-remotes/client' declare const ctx: Context declare const agentCtx: AgentContext @@ -71,9 +71,9 @@ await ctx.api.goals.create(agentId, { objective: 'ship it' }) await agentCtx.goals.create({ objective: 'ship it' }) ``` -Client 应用只装配 `@deepseek-ai/dsh-client-remotes`。该包以运行时值导入被选业务包的 `/remote` 子路径,并向 `ctx.api` 挂载贡献,同时重新导出相同文件中的声明合并。增加一个 Host Remote 包是 Client 组合所有者的显式选择;业务组件不需要分别加载 Host API Gateway 或业务包的 Remote JS。 +Client 应用只装配 `@deepseek-ai/dsh-api-remotes`。该包以运行时值导入被选业务包的 `/remote` 子路径,并向 `ctx.api` 挂载贡献,同时重新导出相同文件中的声明合并。增加一个 Host Remote 包是 Client 组合所有者的显式选择;业务组件不需要分别加载 TypeRT Gateway 或业务包的 Remote JS。 -未来的 TUI 可以装配同一个不依赖 React 的 `client-remotes` 与 `ctx.api` 契约,因此它能看到的 Host 方法同样只限于生成时选择的 Remote 方法。本文不定义或实现 TUI 组合。 +未来的 TUI 可以装配同一个不依赖 React 的 `api-remotes` 与 `ctx.api` 契约,因此它能看到的 Host 方法同样只限于生成时选择的 Remote 方法。本文不定义或实现 TUI 组合。 ## 组件职责 @@ -82,12 +82,13 @@ Client 应用只装配 `@deepseek-ai/dsh-client-remotes`。该包以运行时值 | 共享 | `@deepseek-ai/dsh-type-meta` | 声明 decorator、Gateway binding、可合并协议映射、调用描述符及提供方类型;不启动 TypeScript 分析,也不注册 Cordis 服务 | | 构建 | `@deepseek-ai/dsh-typert-generator` | 从 Host `ts.Program` 严格分析 Remote 签名、类型图、lookup、Context 与源码位置,并生成 Host 和 Host-for-Client 产物 | | Host | `@deepseek-ai/dsh-typert-registry` 与 Loader | 把生成的 Host 描述符、schema 及业务包注册项放入 `ctx.typert`,并持有 lookup 与 Context 提供方 | -| Host | `@deepseek-ai/dsh-host-api-gateway` | 提供 `ctx.typertGateway`,认领 Remote endpoint,解析对象或 Context,调用实时 Cordis Service 并校验边界 | -| Client | `@deepseek-ai/dsh-host-api-gateway/client` | 提供 `ctx.api`,把生成的描述符挂成具体方法,并通过 Connection 发起、校验和取消调用 | -| Client | `@deepseek-ai/dsh-client-remotes/client` | 显式选择并挂载本应用允许使用的 `/remote` 贡献,向业务代码带入对应的声明合并 | +| Host | `@deepseek-ai/dsh-api-remotes` | 负责应用的 Agent/Session 身份策略,并配置对应的 TypeRT lookup | +| Host | `@deepseek-ai/dsh-api-gateway` | 提供 `ctx.typertGateway`,认领 Remote endpoint,解析对象或 Context,调用实时 Cordis Service 并校验边界 | +| Client | `@deepseek-ai/dsh-api-gateway/client` | 提供 `ctx.api`,把生成的描述符挂成具体方法,并通过 Connection 发起、校验和取消调用 | +| Client | `@deepseek-ai/dsh-api-remotes/client` | 显式选择并挂载本应用允许使用的 `/remote` 贡献,向业务代码带入对应的声明合并 | | 双侧 | `@deepseek-ai/dsh-client-connection` | 提供 RPC carrier、请求关联、信任边界、取消、响应 envelope 与当前 `/api` HTTP bridge | -Host API Gateway 包同时拥有 Host dispatcher 与 Client API 两个对等入口,但两侧构建不会进入同一个 `ts.Program`。Host 入口不导入 Client 的 Cordis `Context` 合并,Client 入口也不导入 Host Gateway 服务。 +API Gateway 包同时拥有 Host dispatcher 与 Client API 两个对等入口,但两侧构建不会进入同一个 `ts.Program`。Host 入口不导入 Client 的 Cordis `Context` 合并,Client 入口也不导入 Host Gateway 服务。 ## 严格生成链路 @@ -99,7 +100,7 @@ Host API Gateway 包同时拥有 Host dispatcher 与 Client API 两个对等入 |---|---|---| | `typert.host.js` | Host Loader | Host face 的运行时反射、严格调用描述符和 schema 注册值 | | `typert.host.d.ts` | Host 类型系统 | Host face 的生成声明 | -| `typert.remote-client.js` | `client-remotes` | 可挂载的 `TypeRTRemoteContribution`,包含严格描述符与运行时 codec | +| `typert.remote-client.js` | `api-remotes` | 可挂载的 `TypeRTRemoteContribution`,包含严格描述符与运行时 codec | | `typert.remote-client.d.ts` | Client 类型系统 | `TypeRTRemoteNamespaceMap` 与 `TypeRTRemoteContextMap` 的声明合并及 Client-safe 类型引用 | | `typert.remote-client.d.ts.map` | 编辑器 | 将生成的方法属性映射回 Host 包中的 Remote 方法声明 | @@ -117,7 +118,7 @@ Connection 在 HTTP bridge 之前执行 `/api` 的统一信任检查,再在共 Gateway 每次调用都从当前注册表解析描述符和实时 Service,不缓存业务对象。它要求 `args` 的字段集合与描述符完全一致,先用 codec 校验 wire 值,再通过注册的 lookup 或 Context provider 解析对象或接收者,最后调用 binding 指向的 Service 方法并校验返回值。缺少 provider、identity 未命中、binding 不一致、参数多缺、schema 失败和方法不存在都在进入或离开业务边界时失败。 -lookup provider 的 `register()` 同时提供稳定声明和默认 resolver;`configure()` 提供由 Host 组合拥有、可异步执行且受 effect 生命周期约束的 resolver。配置可以先于 provider 挂载;没有 provider 时调用仍以 `lookup-unavailable` 失败,配置卸载后则恢复 provider 默认策略。标准 Web Host 的 API Proxy 为 `agent` 与 `session` 配置同一套 `agentFor()` 语义:复用 live Agent,自动恢复普通冷会话,对并发恢复去重,并拒绝由 subagent routing 拥有的 identity;`session` lookup 返回该 Agent 的 Session。恢复失败和 ownership fence 通过既有 RPC error 原样返回,不折叠为 Gateway 的 `internal` 错误。 +lookup provider 的 `register()` 同时提供稳定声明和默认 resolver;`configure()` 提供由 Host 组合拥有、可异步执行且受 effect 生命周期约束的 resolver。配置可以先于 provider 挂载;没有 provider 时调用仍以 `lookup-unavailable` 失败,配置卸载后则恢复 provider 默认策略。API Remotes 负责 `agent` 与 `session` 的标准 `agentFor()` 语义:复用 live Agent,自动恢复普通冷会话,对并发恢复去重,并拒绝由 subagent routing 拥有的 identity;`session` lookup 返回该 Agent 的 Session。Web API Proxy 提供 Agent 默认值与 scope 设置,再让旧方法使用同一个 resolver。恢复失败和 ownership fence 通过既有 RPC error 原样返回,不折叠为 Gateway 的 `internal` 错误。 Client 卸载一个贡献时会一起移除描述符和具体方法,中止其进行中的调用,并使外部仍持有的旧方法句柄拒绝继续调用。Host 上已经注册过的严格 endpoint 被撤回后也不会降级到 SRC 推断,以免热卸载悄然降低校验强度。 @@ -158,4 +159,6 @@ pnpm run build:lib:contracts Remote 只处理有单个请求与单个结果的一元方法调用。Session event stream、分页、增量 reduce、projection 和实体子流需要独立的数据协议与注册模型;即使它们复用 Connection,也不应伪装成 Remote 方法或放入调用描述符。 +API 各层按 `remotes → gateway → connection → webserver` 组织。BFF 与 TypeRT RPC 层位于 `packages/api`;Connection 与 WebServer 仍位于 `packages/client/connection` 和 `packages/host/webserver`,其服务契约允许未来只移动包,将它们放到 `packages/api`。旧 API Proxy 仍位于 `packages/host/apiproxy`,作为尚未迁移到 Remote 的 endpoint 的回退路径。 + 当前 lookup 策略按 key 配置,因此所有 `agent` 或 `session` 参数共享冷恢复行为。某个 Remote endpoint 若必须只接受 live 对象,需要后续增加显式的逐参数或逐 endpoint 策略,不能通过业务方法内部猜测恢复来源。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 774bc296b1..0164acefcf 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/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 docs/architecture.md -architecture.md: db5991d98dfbc6b04992d62d5a465c375c9a78b8 -architecture.zh.md: 2eb8c3834a6ffc3283c8aa669be481b534bb5914 +architecture.md: 35a73d4a307f5f48cc41cc496742a2ac210e8877 +architecture.zh.md: 185958221a477bb690e3ab5c91c33ba892ab2d73 diff --git a/docs/architecture.md b/docs/architecture.md index db5991d98d..35a73d4a30 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -48,7 +48,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts; packages contribute services, | `ctx.credentials` | [`credentials/`](../packages/credentials/README.md) | named secret references resolved per operation, never inlined in configuration | | `ctx.directoryPicker` | [`host/directory-picker`](../packages/host/directory-picker/README.md) | GUI-host directory picking (`native`/`browse` interactions) | | `ctx.typert` | [`typert/registry`](../packages/typert/registry/README.md) | runtime registry for generated package reflection and live Zod schemas | -| `ctx.typertGateway` | [`host/api-gateway`](../packages/host/api-gateway/README.md) | dispatches TypeRT Remote unary calls through the [API Gateway](api-gateway.md) | +| `ctx.typertGateway` | [`api/gateway`](../packages/api/gateway/README.md) | dispatches TypeRT Remote unary calls through the [API Gateway](api-gateway.md) | | `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | package-name-selected registry of package-owned runtime checks | ## Event diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 2eb8c3834a..185958221a 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -48,7 +48,7 @@ | `ctx.credentials` | [`credentials/`](../packages/credentials/README.md) | 具名密钥引用,按操作解析,绝不内联进配置 | | `ctx.directoryPicker` | [`host/directory-picker`](../packages/host/directory-picker/README.md) | GUI 宿主目录选取(`native`/`browse` 交互) | | `ctx.typert` | [`typert/registry`](../packages/typert/registry/README.md) | 生成的包反射和实时 Zod schema 的运行时注册表 | -| `ctx.typertGateway` | [`host/api-gateway`](../packages/host/api-gateway/README.md) | 通过 [API Gateway](api-gateway.md) 分发 TypeRT Remote 一元调用 | +| `ctx.typertGateway` | [`api/gateway`](../packages/api/gateway/README.md) | 通过 [API Gateway](api-gateway.md) 分发 TypeRT Remote 一元调用 | | `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | 按包名筛选包自有运行时检查的注册表 | ## 事件 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 18839bf3c2..2de89ef94b 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -367,8 +367,8 @@ flowchart LR | `ctx.toolResultPrune` | `core` | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | - | [`compact-basic`](../packages/compact/compact-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), [`cli-demo`](../packages/examples/cli-demo), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | | `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. | -| `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), `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` | - | - | - | Associates generated Remote descriptors with live Cordis services, resolves registered identities, and exposes unary calls through the shared Connection RPC carrier. | +| `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-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | | `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-local`](../packages/settings/settings-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `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.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), `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. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 77baad512d..21f38d18e2 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2520,9 +2520,10 @@ Source: [`packages/context/workspace-context/src/config.ts:18`](../packages/cont These load from a `cordis.yml` entry with no `config:` block; they declare no config surface. - `@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` ([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts)) - `@deepseek-ai/dsh-client-locale` ([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) - `@deepseek-ai/dsh-client-modules` — requires `httpServer` · `loader` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) -- `@deepseek-ai/dsh-client-remotes` ([`packages/client/remotes/src/index.ts`](../packages/client/remotes/src/index.ts)) - `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts)) - `@deepseek-ai/dsh-client-ui-command` ([`packages/client/ui-command/src/index.ts`](../packages/client/ui-command/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) @@ -2549,7 +2550,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-commands` ([`packages/ui/commands/src/index.ts`](../packages/ui/commands/src/index.ts)) - `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts)) - `@deepseek-ai/dsh-goal-session` — requires `agents` · `goals` · `sessions` ([`packages/goal/goal-session/src/index.ts`](../packages/goal/goal-session/src/index.ts)) -- `@deepseek-ai/dsh-host-api-gateway` — requires `typert` ([`packages/host/api-gateway/src/index.ts`](../packages/host/api-gateway/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker-auto` — requires `httpServer` · `loader` ([`packages/host/directory-picker-auto/src/index.ts`](../packages/host/directory-picker-auto/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker-native` ([`packages/host/directory-picker-native/src/index.ts`](../packages/host/directory-picker-native/src/index.ts)) - `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 8acd669131..cf763dca98 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2609,7 +2609,7 @@ Resolve strict generated definitions or conservative SRC markers against current async invoke(request: InvokeRemoteRequest): Promise ``` -Source: [`packages/host/api-gateway/src/index.ts:78`](../../packages/host/api-gateway/src/index.ts) +Source: [`packages/api/gateway/src/index.ts:78`](../../packages/api/gateway/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` diff --git a/docs/core-data-structures/typert.i18n.yaml b/docs/core-data-structures/typert.i18n.yaml index 5b0b70de54..a6e1eb5415 100644 --- a/docs/core-data-structures/typert.i18n.yaml +++ b/docs/core-data-structures/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/core-data-structures/typert.md -typert.md: 1ff0fe80e483d481f686336c86038cdd169ecdbc -typert.zh.md: 3cc0aa26406e01db5a6c05074210fc9d40b8ec00 +typert.md: a61ed8587833e03fd5c1246311e62a6ffaeb3bd0 +typert.zh.md: 18c24018f4abd644cf35185c2bd06b6980195481 diff --git a/docs/core-data-structures/typert.md b/docs/core-data-structures/typert.md index 1ff0fe80e4..a61ed85878 100644 --- a/docs/core-data-structures/typert.md +++ b/docs/core-data-structures/typert.md @@ -2,7 +2,7 @@ English | [中文](typert.zh.md) -Types shared by generated Remote artifacts, the Host Gateway, and consumer API assemblies. The [TypeRT Gateway Agent Note](../../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) owns the architecture and transport decisions; this page records the literal public contracts from [`dsh-type-meta`](../../packages/typert/type-meta/src/types.ts) and [`dsh-host-api-gateway`](../../packages/host/api-gateway/src/types.ts). +Types shared by generated Remote artifacts, the Host Gateway, and consumer API assemblies. The [TypeRT Gateway Agent Note](../../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) owns the architecture and transport decisions; this page records the literal public contracts from [`dsh-type-meta`](../../packages/typert/type-meta/src/types.ts) and [`dsh-api-gateway`](../../packages/api/gateway/src/types.ts). ## Lookup and Context declarations @@ -126,7 +126,7 @@ interface TypeRTService { } ``` -Generated consumer declarations merge direct namespaces into the map inherited by `ClientApi`. +Generated consumer declarations merge direct namespaces into the map inherited by `TypeRTClientApi`. ```ts type-equiv /** Merge-extensible direct namespace surface generated for Client API services. */ @@ -191,8 +191,8 @@ interface TypertGateway { `ctx.api` exposes only namespaces contributed by imported `/remote` artifacts. Mounting installs the generated descriptors and concrete root/scoped methods as one fiber-owned operation; no JavaScript Proxy or Host Service type enters the consumer. ```ts type-equiv -/** Typed API service augmented by generated direct Remote namespaces. */ -interface ClientApi extends TypeRTRemoteNamespaceMap { +/** Client API capability implemented by the Gateway and consumed by Remote assemblies. */ +interface TypeRTClientApi extends TypeRTRemoteNamespaceMap { /** * Mount one generated Host-for-Client contribution in the caller's fiber. * @param contribution - explicitly selected Remote package artifact. diff --git a/docs/core-data-structures/typert.zh.md b/docs/core-data-structures/typert.zh.md index 3cc0aa2640..18c24018f4 100644 --- a/docs/core-data-structures/typert.zh.md +++ b/docs/core-data-structures/typert.zh.md @@ -2,7 +2,7 @@ [English](typert.md) | 中文 -以下类型由生成的 Remote 产物、Host Gateway 与消费方 API assembly 共用。[TypeRT Gateway Agent Note](../../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) 负责架构与传输决策;本页记录 [`dsh-type-meta`](../../packages/typert/type-meta/src/types.ts) 和 [`dsh-host-api-gateway`](../../packages/host/api-gateway/src/types.ts) 中公共契约的字面定义。 +以下类型由生成的 Remote 产物、Host Gateway 与消费方 API assembly 共用。[TypeRT Gateway Agent Note](../../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) 负责架构与传输决策;本页记录 [`dsh-type-meta`](../../packages/typert/type-meta/src/types.ts) 和 [`dsh-api-gateway`](../../packages/api/gateway/src/types.ts) 中公共契约的字面定义。 ## Lookup 与 Context 声明 @@ -126,7 +126,7 @@ interface TypeRTService { } ``` -生成的消费方声明会把 direct namespace 合并到 `ClientApi` 继承的 map 中。 +生成的消费方声明会把 direct namespace 合并到 `TypeRTClientApi` 继承的 map 中。 ```ts type-equiv /** Merge-extensible direct namespace surface generated for Client API services. */ @@ -191,8 +191,8 @@ interface TypertGateway { `ctx.api` 只暴露由已导入 `/remote` 产物贡献的 namespace。挂载会把生成的 descriptor 与具体的 root/scoped 方法作为一项由 fiber 持有的操作统一注册;JavaScript Proxy 与 Host 服务类型都不会进入消费方。 ```ts type-equiv -/** Typed API service augmented by generated direct Remote namespaces. */ -interface ClientApi extends TypeRTRemoteNamespaceMap { +/** Client API capability implemented by the Gateway and consumed by Remote assemblies. */ +interface TypeRTClientApi extends TypeRTRemoteNamespaceMap { /** * Mount one generated Host-for-Client contribution in the caller's fiber. * @param contribution - explicitly selected Remote package artifact. diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 2ea336b1f0..b0809af72e 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: d480f548dd24ea81d132e4b4c0cc364ce1b0cd53 -development.zh.md: 08ef7fd2d3da7db83eb3ca4dff9f9c85f6d7cb5e +development.md: f832956c4c7cbde96613a69db6c636a2246786a7 +development.zh.md: 3ae70e7135ad5faee0e37d99f55cdb41373aab2c diff --git a/docs/development.md b/docs/development.md index d480f548dd..f832956c4c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -62,7 +62,7 @@ Host and client stay two aggregate programs because both sides declaration-merge Static analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md). -Business services declare callable methods on the Host with `@Remote` or `@RemoteContext`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `client-remotes` composition loads those contributions and mounts concrete methods on `ctx.api` or the corresponding scoped Context. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order. +Business services declare callable methods on the Host with `@Remote` or `@RemoteContext`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions and mounts concrete methods on `ctx.api` or the corresponding scoped Context. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order. If a relevant local check consumes built package output, build once first: diff --git a/docs/development.zh.md b/docs/development.zh.md index 08ef7fd2d3..3ae70e7135 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -62,7 +62,7 @@ host 与 client 保持两个聚合 program,是因为两侧在相同键下以 静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。 -业务 Service 在 Host 使用 `@Remote` 或 `@RemoteContext` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `client-remotes` 组合加载这些贡献并将具体方法挂载到 `ctx.api` 或对应的作用域 Context。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。 +业务 Service 在 Host 使用 `@Remote` 或 `@RemoteContext` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并将具体方法挂载到 `ctx.api` 或对应的作用域 Context。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。 如果相关的本地检查需要使用构建后的包产物,请先构建一次: diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 34f4d37ffd..92bf908613 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -66,7 +66,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `credentials/changed` | `runtime` (`emit`) | `ui-models` | | `internal/dispatch` | - | [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `hmr`, `loader`, `modules`, `webserver` | -| `internal/service` | - | `api-gateway` | +| `internal/service` | - | `gateway` | | `internal/status` | - | [`agent`](../packages/core/agent) | | `locale/change` | `locale` (`emit`) | `locale` | | `models/changed` | `runtime` (`emit`) | `ui-models` | diff --git a/docs/module-graph.md b/docs/module-graph.md index ac46e968b3..43923e0865 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -146,6 +146,10 @@ flowchart TD pkg_user_approval["user-approval"] pkg_user_interaction["user-interaction"] end + subgraph group_api["packages/api"] + pkg_api_gateway["api-gateway"] + pkg_api_remotes["api-remotes"] + end subgraph group_bundle["packages/bundle"] pkg_base["base"] pkg_headless["headless"] @@ -156,7 +160,6 @@ flowchart TD pkg_client_hmr["client-hmr"] pkg_client_locale["client-locale"] pkg_client_modules["client-modules"] - pkg_client_remotes["client-remotes"] pkg_client_runtime["client-runtime"] pkg_client_schema_form["client-schema-form"] pkg_client_test_runtime["client-test-runtime"] @@ -212,7 +215,6 @@ flowchart TD end subgraph group_host["packages/host"] pkg_frontend_static["frontend-static"] - pkg_host_api_gateway["host-api-gateway"] pkg_host_apiproxy["host-apiproxy"] pkg_host_directory_picker["host-directory-picker"] pkg_host_directory_picker_auto["host-directory-picker-auto"] @@ -367,13 +369,13 @@ flowchart TD pkg_system_prompt --> pkg_scope pkg_web --> pkg_invariants pkg_web --> pkg_llm + pkg_api_gateway --> pkg_client_connection + pkg_api_gateway --> pkg_invariants + pkg_api_gateway --> pkg_typert_registry pkg_credentials_local --> pkg_atomic_write pkg_credentials_local --> pkg_credentials pkg_credentials_local --> pkg_invariants pkg_credentials_local --> pkg_paths - pkg_host_api_gateway --> pkg_client_connection - pkg_host_api_gateway --> pkg_invariants - pkg_host_api_gateway --> pkg_typert_registry pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm @@ -614,6 +616,7 @@ flowchart TD pkg_permission --> pkg_session_projection pkg_permission --> pkg_settings pkg_permission --> pkg_user_approval +<<<<<<< HEAD <<<<<<< HEAD pkg_client_ui_conversation --> pkg_client_locale pkg_client_ui_conversation --> pkg_client_runtime @@ -630,6 +633,14 @@ flowchart TD pkg_client_remotes --> pkg_host_api_gateway pkg_client_remotes --> pkg_invariants >>>>>>> d6e02ebbcc (feat(goal): add TypeRT gateway example) +======= + pkg_api_remotes --> pkg_agent + pkg_api_remotes --> pkg_goal + pkg_api_remotes --> pkg_invariants + pkg_api_remotes --> pkg_session + pkg_api_remotes --> pkg_session_persistence + pkg_api_remotes --> pkg_typert_registry +>>>>>>> c671d9e48c (refactor(api): colocate gateway and remote assembly) pkg_pty_local --> pkg_agent pkg_pty_local --> pkg_invariants pkg_pty_local --> pkg_pty @@ -783,6 +794,7 @@ flowchart TD pkg_tool_ask_user --> pkg_invariants pkg_tool_ask_user --> pkg_tools pkg_tool_ask_user --> pkg_user_interaction +<<<<<<< HEAD <<<<<<< HEAD pkg_client_ui_command --> pkg_client_connection pkg_client_ui_command --> pkg_client_locale @@ -815,6 +827,9 @@ flowchart TD pkg_client_ui_skill --> pkg_invariants ======= pkg_client_runtime --> pkg_client_remotes +======= + pkg_client_runtime --> pkg_api_remotes +>>>>>>> c671d9e48c (refactor(api): colocate gateway and remote assembly) pkg_client_runtime --> pkg_invariants pkg_client_runtime --> pkg_type_meta pkg_client_runtime --> pkg_typert_registry @@ -1132,8 +1147,8 @@ flowchart TD pkg_client_ui_command --> pkg_client_ui_slash pkg_client_ui_command --> pkg_client_ui_slots pkg_client_ui_command --> pkg_invariants + pkg_client_ui_goal --> pkg_api_remotes pkg_client_ui_goal --> pkg_client_locale - pkg_client_ui_goal --> pkg_client_remotes pkg_client_ui_goal --> pkg_client_runtime pkg_client_ui_goal --> pkg_client_ui_conversation pkg_client_ui_goal --> pkg_client_ui_primitives @@ -1231,8 +1246,8 @@ flowchart TD | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`type-meta`](../packages/typert/type-meta) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | -| [`host-api-gateway`](../packages/host/api-gateway) | `host` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) | @@ -1295,11 +1310,15 @@ flowchart TD | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/ui/user-approval) | <<<<<<< HEAD +<<<<<<< HEAD | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | ======= | [`client-remotes`](../packages/client/remotes) | `client` | [`goal`](../packages/goal/goal), [`host-api-gateway`](../packages/host/api-gateway), [`invariants`](../packages/support/invariants) | >>>>>>> d6e02ebbcc (feat(goal): add TypeRT gateway example) +======= +| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`typert-registry`](../packages/typert/registry) | +>>>>>>> c671d9e48c (refactor(api): colocate gateway and remote assembly) | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -1326,6 +1345,7 @@ flowchart TD | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | <<<<<<< HEAD +<<<<<<< HEAD | [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | @@ -1333,6 +1353,9 @@ flowchart TD ======= | [`client-runtime`](../packages/client/runtime) | `client` | [`client-remotes`](../packages/client/remotes), [`invariants`](../packages/support/invariants), [`type-meta`](../packages/typert/type-meta), [`typert-registry`](../packages/typert/registry) | >>>>>>> d6e02ebbcc (feat(goal): add TypeRT gateway example) +======= +| [`client-runtime`](../packages/client/runtime) | `client` | [`api-remotes`](../packages/api/remotes), [`invariants`](../packages/support/invariants), [`type-meta`](../packages/typert/type-meta), [`typert-registry`](../packages/typert/registry) | +>>>>>>> c671d9e48c (refactor(api): colocate gateway and remote assembly) | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | @@ -1383,7 +1406,7 @@ flowchart TD | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-locale`](../packages/client/locale), [`client-remotes`](../packages/client/remotes), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | +| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`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/support/invariants) | diff --git a/knip.json b/knip.json index 3ce9a32d99..3d7836104f 100644 --- a/knip.json +++ b/knip.json @@ -115,7 +115,7 @@ "tests/**/*.ts" ] }, - "packages/client/remotes": { + "packages/api/remotes": { "entry": [ "tests/**/*.e2e.ts" ], diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index d10f476f79..8aa9b92b91 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: 8fbb6069a784a5bd45423a4e1ae11834a597750d -README.zh.md: 42a8d691344c716021188df6fd870a841d543f36 +README.md: 229feae568ba6e40a9c633696097eff46fd5bc95 +README.zh.md: b84aef020a7e3edf305df709d399fbc7b093b6a3 diff --git a/packages/README.md b/packages/README.md index 8fbb6069a7..229feae568 100644 --- a/packages/README.md +++ b/packages/README.md @@ -11,6 +11,7 @@ Packages live at `packages///`; groups are containers, while names r | Group | Role | Release expectation | |---|---|---| | [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable surface | +| [`api/`](api/README.md) | Remote BFF assembly and TypeRT RPC gateway | Product — stable surface | | [`typert/`](typert/README.md) | Type graph generation, artifact loading, and runtime registry | Product — stable surface | | [`goal/`](goal/README.md) | Same-session goal persistence and lifecycle | Product — stable surface | | [`feedback/`](feedback/README.md) | Human feedback | Product — stable surface | diff --git a/packages/README.zh.md b/packages/README.zh.md index 42a8d69134..b84aef020a 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -11,6 +11,7 @@ | 组 | 职责 | 发布预期 | |---|---|---| | [`core/`](core/README.md) | 产品 API 主干:会话、提示词、工具、agent(智能体)服务与具体循环 | 产品:稳定表面 | +| [`api/`](api/README.md) | Remote BFF 装配与 TypeRT RPC Gateway | 产品:稳定表面 | | [`typert/`](typert/README.md) | 类型图生成、产物加载与运行时注册表 | 产品:稳定表面 | | [`goal/`](goal/README.md) | 同会话 goal 的持久化与生命周期 | 产品:稳定表面 | | [`feedback/`](feedback/README.md) | 人类反馈 | 产品:稳定表面 | diff --git a/packages/client/remotes/README.i18n.yaml b/packages/api/README.i18n.yaml similarity index 56% rename from packages/client/remotes/README.i18n.yaml rename to packages/api/README.i18n.yaml index 86f2aded18..855eeb8eaa 100644 --- a/packages/client/remotes/README.i18n.yaml +++ b/packages/api/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write packages/client/remotes/README.md -README.md: e29188b8e3ae5ecefe194f1355558e9bdeaae7dd -README.zh.md: e6425ab190a28e0a38c3713c4e21645789a8f00c +# pnpm run verify-translation-pairing --write packages/api/README.md +README.md: 0dcded5922fea1ea6676315029ba0eadd74dd3df +README.zh.md: 1b9bb9133a955d0cbef0ca91728aab1545831d94 diff --git a/packages/api/README.md b/packages/api/README.md new file mode 100644 index 0000000000..0dcded5922 --- /dev/null +++ b/packages/api/README.md @@ -0,0 +1,17 @@ +# api/ — Remote API layers + +English | [中文](README.zh.md) + +The application-facing Remote stack. `remotes` owns BFF policy and the selected business API, while `gateway` implements the TypeRT unary RPC endpoints shared by Host and Client environments. + +| Package | Role | ctx key | +|---|---|---| +| [`remotes/`](remotes/README.md) | Host Agent/Session lookup policy and Client Remote contribution assembly | no service; configures `ctx.typert` and consumes `ctx.api` | +| [`gateway/`](gateway/README.md) | Host TypeRT dispatcher and Client API endpoint | `ctx.typertGateway` / `ctx.api` | + +The runtime dependency direction is `remotes → gateway → connection → webserver`: the BFF consumes the shared `TypeRTClientApi` contract, Gateway delegates transport to Connection, and Connection mounts on the HTTP server. Cordis service injection and Client module metadata preserve this order without importing the concrete Gateway from the Remotes Client entry. + +## Known Limitations and Deferred Work + +- Connection and WebServer remain at [`client/connection`](../client/connection/README.md) and [`host/webserver`](../host/webserver/README.md); a later package-only move can place them under `api/connection` and `api/webserver` without changing their service contracts. +- The legacy API Proxy remains at [`host/apiproxy`](../host/apiproxy/README.md) as the fallback for methods not yet migrated to Remote. It consumes the Host resolver owned by `api-remotes` so migrated and legacy methods retain one Agent/Session identity policy. diff --git a/packages/api/README.zh.md b/packages/api/README.zh.md new file mode 100644 index 0000000000..1b9bb9133a --- /dev/null +++ b/packages/api/README.zh.md @@ -0,0 +1,17 @@ +# api/:Remote API 层 + +[English](README.md) | 中文 + +面向应用的 Remote 技术栈。`remotes` 负责 BFF 策略和选定的业务 API,`gateway` 则实现 Host 与 Client 环境共用的 TypeRT 一元 RPC endpoint。 + +| 包 | 职责 | ctx key | +|---|---|---| +| [`remotes/`](remotes/README.md) | Host Agent/Session lookup 策略与 Client Remote contribution 装配 | 无服务;配置 `ctx.typert` 并消费 `ctx.api` | +| [`gateway/`](gateway/README.md) | Host TypeRT 分发器与 Client API endpoint | `ctx.typertGateway` / `ctx.api` | + +运行时依赖方向为 `remotes → gateway → connection → webserver`:BFF 消费共享的 `TypeRTClientApi` 契约,Gateway 把传输交给 Connection,Connection 再挂载到 HTTP server。Cordis 服务注入与 Client 模块元数据在不让 Remotes Client 入口导入具体 Gateway 实现的前提下维持该顺序。 + +## 已知限制与延期工作 + +- Connection 与 WebServer 仍位于 [`client/connection`](../client/connection/README.md) 和 [`host/webserver`](../host/webserver/README.md);后续可以只移动包,将它们放到 `api/connection` 和 `api/webserver` 下,而无需改变服务契约。 +- 旧 API Proxy 仍位于 [`host/apiproxy`](../host/apiproxy/README.md),作为尚未迁移到 Remote 的方法的回退路径。它使用由 `api-remotes` 持有的 Host resolver,使已迁移与旧方法共用同一套 Agent/Session 身份策略。 diff --git a/packages/host/api-gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml similarity index 56% rename from packages/host/api-gateway/README.i18n.yaml rename to packages/api/gateway/README.i18n.yaml index 8d8d699c7a..41bbb0621f 100644 --- a/packages/host/api-gateway/README.i18n.yaml +++ b/packages/api/gateway/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write packages/host/api-gateway/README.md -README.md: eb48c29628d39e381235b1f72754eb114960b1ad -README.zh.md: e53bb6c216e42fe2e970bf2cb80eac9ea7426497 +# pnpm run verify-translation-pairing --write packages/api/gateway/README.md +README.md: 9e3d4d89788bbc6edebfc0c0127999fed3ed9261 +README.zh.md: 9bbd46c71185a2fbf8da163565d6c19141c079ca diff --git a/packages/host/api-gateway/README.md b/packages/api/gateway/README.md similarity index 86% rename from packages/host/api-gateway/README.md rename to packages/api/gateway/README.md index eb48c29628..9e3d4d8978 100644 --- a/packages/host/api-gateway/README.md +++ b/packages/api/gateway/README.md @@ -1,8 +1,8 @@ -# @deepseek-ai/dsh-host-api-gateway +# @deepseek-ai/dsh-api-gateway English | [中文](README.zh.md) -Two-sided Remote control for Host and Client Cordis environments. The Host entry provides `ctx.typertGateway`, while `@deepseek-ai/dsh-host-api-gateway/client` provides `ctx.api`; both consume the same generated `InvocationDescriptor` contract and leave transport, request correlation, trust, and response envelopes to Connection. +Two-sided TypeRT RPC endpoint for Host and Client Cordis environments. The Host entry provides `ctx.typertGateway`, while `@deepseek-ai/dsh-api-gateway/client` provides `ctx.api`; both consume the same generated `InvocationDescriptor` contract and leave business selection to API Remotes and transport, request correlation, trust, and response envelopes to Connection. ## Host service: `TypertGatewayService` (ctx key: `typertGateway`) @@ -20,7 +20,7 @@ A cancellation-aware Remote method declares `signal: AbortSignal` as its final H Each call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api', endpoint, ...)`. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before calling Connection. The returned value is validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls, and makes retained method handles reject. -Generated declaration merges provide the TypeScript API. 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. +Generated declaration merges provide the TypeScript API through the shared `TypeRTClientApi` 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. ## Model Experience diff --git a/packages/host/api-gateway/README.zh.md b/packages/api/gateway/README.zh.md similarity index 86% rename from packages/host/api-gateway/README.zh.md rename to packages/api/gateway/README.zh.md index e53bb6c216..9bbd46c711 100644 --- a/packages/host/api-gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -1,8 +1,8 @@ -# @deepseek-ai/dsh-host-api-gateway +# @deepseek-ai/dsh-api-gateway [English](README.md) | 中文 -为 Host 与 Client 两侧的 Cordis 环境提供 Remote 控制。Host 入口提供 `ctx.typertGateway`,`@deepseek-ai/dsh-host-api-gateway/client` 则提供 `ctx.api`;两者使用同一份生成的 `InvocationDescriptor` 契约,并将传输、请求关联、信任和响应封装交由 Connection 处理。 +为 Host 与 Client 两侧的 Cordis 环境提供 TypeRT RPC endpoint。Host 入口提供 `ctx.typertGateway`,`@deepseek-ai/dsh-api-gateway/client` 则提供 `ctx.api`;两者使用同一份生成的 `InvocationDescriptor` 契约,并将业务选择交给 API Remotes,将传输、请求关联、信任和响应封装交给 Connection。 ## Host 服务:`TypertGatewayService`(ctx key:`typertGateway`) @@ -20,7 +20,7 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandle 每次调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用 Connection 前将它与贡献项的挂载生命周期合并。返回值经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用,并使外部仍持有的方法句柄在调用时返回拒绝。 -生成的声明合并提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。 +生成的声明合并通过共享的 `TypeRTClientApi` 契约提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。 ## 模型体验 diff --git a/packages/host/api-gateway/package.json b/packages/api/gateway/package.json similarity index 92% rename from packages/host/api-gateway/package.json rename to packages/api/gateway/package.json index 794ae323aa..fa351d84bf 100644 --- a/packages/host/api-gateway/package.json +++ b/packages/api/gateway/package.json @@ -1,6 +1,6 @@ { - "name": "@deepseek-ai/dsh-host-api-gateway", - "description": "Host dispatcher and Client API for TypeRT Remote invocations", + "name": "@deepseek-ai/dsh-api-gateway", + "description": "TypeRT Remote Host dispatcher and Client API endpoint", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/host/api-gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts similarity index 96% rename from packages/host/api-gateway/src/client/index.ts rename to packages/api/gateway/src/client/index.ts index 5503fc3dcf..bafffa80f7 100644 --- a/packages/host/api-gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -9,10 +9,9 @@ import type { Context } from 'cordis' import type { ConnectionHandle, RpcError } from '@deepseek-ai/dsh-client-connection/client' import type { InvocationDescriptor, + TypeRTClientApi, TypeRTCodec, - TypeRTDisposer, TypeRTRemoteContribution, - TypeRTRemoteNamespaceMap, } from '@deepseek-ai/dsh-type-meta' type RemoteMethod = (...args: unknown[]) => Promise @@ -40,14 +39,7 @@ interface ScopedProjection { } /** Typed API service augmented by generated direct Remote namespaces. */ -export interface ClientApi extends TypeRTRemoteNamespaceMap { - /** - * Mount one generated Host-for-Client contribution in the caller's fiber. - * @param contribution - explicitly selected Remote package artifact. - * @returns disposer withdrawing descriptors and concrete methods together. - */ - mount(contribution: TypeRTRemoteContribution): TypeRTDisposer -} +export type ClientApi = TypeRTClientApi declare module 'cordis' { interface Context { @@ -67,7 +59,7 @@ export function apply(ctx: Context): void { new ClientApiService(ctx) } -class ClientApiService extends Service implements ClientApi { +class ClientApiService extends Service implements TypeRTClientApi { private readonly ownerCtx: Context private readonly direct = new Map() private readonly scoped = new Map() @@ -77,7 +69,7 @@ class ClientApiService extends Service implements ClientApi { this.ownerCtx = ctx } - mount(contribution: TypeRTRemoteContribution): TypeRTDisposer { + mount(contribution: TypeRTRemoteContribution): ReturnType { this.validateContribution(contribution) const callerCtx = this.ctx const disposeRemote = callerCtx.typert.remotes.register(contribution) diff --git a/packages/host/api-gateway/src/index.ts b/packages/api/gateway/src/index.ts similarity index 99% rename from packages/host/api-gateway/src/index.ts rename to packages/api/gateway/src/index.ts index 8ea26b5990..13cf460f4d 100644 --- a/packages/host/api-gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -1,7 +1,7 @@ /** * Live TypeRT Remote dispatch over Cordis Services and registered providers. * Transport, request correlation, and response envelopes belong to Connection. - * @module @deepseek-ai/dsh-host-api-gateway + * @module @deepseek-ai/dsh-api-gateway */ import { Context, Service, symbols } from 'cordis' diff --git a/packages/host/api-gateway/src/invariant.ts b/packages/api/gateway/src/invariant.ts similarity index 77% rename from packages/host/api-gateway/src/invariant.ts rename to packages/api/gateway/src/invariant.ts index 65c94b4ac4..711c4edab5 100644 --- a/packages/host/api-gateway/src/invariant.ts +++ b/packages/api/gateway/src/invariant.ts @@ -1,16 +1,16 @@ /** - * Package-owned invariant companion for `@deepseek-ai/dsh-host-api-gateway`. - * @module @deepseek-ai/dsh-host-api-gateway/invariant + * Package-owned invariant companion for `@deepseek-ai/dsh-api-gateway`. + * @module @deepseek-ai/dsh-api-gateway/invariant */ /* jscpd:ignore-start */ import type { Context } from 'cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' -const PACKAGE_NAME = '@deepseek-ai/dsh-host-api-gateway' +const PACKAGE_NAME = '@deepseek-ai/dsh-api-gateway' /** Cordis companion plugin name. */ -export const name = 'host-api-gateway-invariant' +export const name = 'api-gateway-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] diff --git a/packages/host/api-gateway/src/types.ts b/packages/api/gateway/src/types.ts similarity index 97% rename from packages/host/api-gateway/src/types.ts rename to packages/api/gateway/src/types.ts index f4bb276c22..0917ba2ca6 100644 --- a/packages/host/api-gateway/src/types.ts +++ b/packages/api/gateway/src/types.ts @@ -1,6 +1,6 @@ /** * Carrier-independent TypeRT Gateway request, service, and error contracts. - * @module @deepseek-ai/dsh-host-api-gateway/types + * @module @deepseek-ai/dsh-api-gateway/types */ /** One Remote method request after a carrier has decoded its envelope. */ diff --git a/packages/host/api-gateway/tests/client.spec.ts b/packages/api/gateway/tests/client.spec.ts similarity index 100% rename from packages/host/api-gateway/tests/client.spec.ts rename to packages/api/gateway/tests/client.spec.ts diff --git a/packages/host/api-gateway/tests/gateway.spec.ts b/packages/api/gateway/tests/gateway.spec.ts similarity index 99% rename from packages/host/api-gateway/tests/gateway.spec.ts rename to packages/api/gateway/tests/gateway.spec.ts index 0871dc2761..d784a1ac2f 100644 --- a/packages/host/api-gateway/tests/gateway.spec.ts +++ b/packages/api/gateway/tests/gateway.spec.ts @@ -16,7 +16,7 @@ import { type TypeRTLookupProvider, } from '@deepseek-ai/dsh-type-meta' import TypertRegistry, { type TypertContribution } from '@deepseek-ai/dsh-typert-registry' -import TypertGatewayService, { TypertGatewayError } from '@deepseek-ai/dsh-host-api-gateway' +import TypertGatewayService, { TypertGatewayError } from '@deepseek-ai/dsh-api-gateway' interface FixtureAgent { readonly id: string diff --git a/packages/host/api-gateway/tsconfig.json b/packages/api/gateway/tsconfig.json similarity index 100% rename from packages/host/api-gateway/tsconfig.json rename to packages/api/gateway/tsconfig.json diff --git a/packages/api/gateway/tsdown.config.ts b/packages/api/gateway/tsdown.config.ts new file mode 100644 index 0000000000..f9049b6067 --- /dev/null +++ b/packages/api/gateway/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../../client/tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-api-gateway', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/api/remotes/README.i18n.yaml b/packages/api/remotes/README.i18n.yaml new file mode 100644 index 0000000000..c3c13a8049 --- /dev/null +++ b/packages/api/remotes/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/api/remotes/README.md +README.md: cf54a56a849246d4efdca09cadd42e157064bdee +README.zh.md: 5cd7ef21c926440ca4df6d88ee4adfe87defcc3f diff --git a/packages/api/remotes/README.md b/packages/api/remotes/README.md new file mode 100644 index 0000000000..cf54a56a84 --- /dev/null +++ b/packages/api/remotes/README.md @@ -0,0 +1,25 @@ +# @deepseek-ai/dsh-api-remotes + +English | [中文](README.zh.md) + +Two-sided BFF for Host Remote capabilities selected by this application. The Host entry owns Agent/Session identity policy; the Client entry imports generated `/remote` artifacts as runtime values, mounts each contribution through `ctx.api`, and re-exports their declaration merges. Client business packages depend on this facade rather than the Gateway implementation or individual Remote runtime entries. + +`createApiRemoteAgentResolver()` reuses live Agents, resumes ordinary cold sessions, deduplicates concurrent resumes, preserves the subagent ownership fence, and configures the same resolver for TypeRT `agent` and `session` lookups. The standard Web API Proxy supplies its Agent defaults and scope setup, then uses the returned resolver for legacy methods, so migrated and unmigrated methods share one policy implementation. + +The current Client assembly mounts only the Goal Remote contribution. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, concrete root and scoped methods, invocation, and cancellation. The Client entry consumes the shared `TypeRTClientApi` interface through Cordis and does not import the concrete Gateway. + +This package contains no transport or Host service discovery logic. Its Client face can be reused by Web or a future TUI that provides the same React-free `ctx.api` contract. + +## Model Experience + +None, as this BFF selects Remote application methods and identity policy but registers no model surface. + +#### KV Cache effect + +No direct effect; mounted Host capabilities own any model-visible behavior they trigger. + +## Known Limitations and Deferred Work + +- The capability set is fixed by explicit build-time value imports; the Client does not discover the Host's active Services or Remote definitions at runtime. +- Additional capabilities require an explicit `/remote` value import and mount in this assembly. +- The standard Web Host supplies resume defaults and Agent-scope setup from the legacy API Proxy until that remaining BFF configuration moves into `api-remotes`. diff --git a/packages/api/remotes/README.zh.md b/packages/api/remotes/README.zh.md new file mode 100644 index 0000000000..5cd7ef21c9 --- /dev/null +++ b/packages/api/remotes/README.zh.md @@ -0,0 +1,25 @@ +# @deepseek-ai/dsh-api-remotes + +[English](README.md) | 中文 + +为本应用选定的 Host Remote 能力提供双侧 BFF。Host 入口负责 Agent/Session 身份策略;Client 入口以运行时值形式导入生成的 `/remote` 产物,通过 `ctx.api` 挂载每项贡献,并重新导出对应的声明合并。Client 业务包依赖该外观,而不依赖 Gateway 实现或单独的 Remote 运行时入口。 + +`createApiRemoteAgentResolver()` 会复用 live Agent、恢复普通冷会话、对并发恢复去重、保留 subagent ownership fence,并为 TypeRT `agent` 和 `session` lookup 配置同一个 resolver。标准 Web API Proxy 提供 Agent 默认值和 scope 设置,再将返回的 resolver 用于旧方法,使已迁移与未迁移方法共用同一份策略实现。 + +当前 Client 组合仅挂载 Goal Remote 贡献。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、具体的根级方法和作用域方法、调用与取消。Client 入口通过 Cordis 消费共享的 `TypeRTClientApi` 接口,不导入具体 Gateway。 + +本包不包含传输逻辑或 Host 服务发现逻辑。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.api` 契约,均可复用其 Client face。 + +## 模型体验 + +无,因为该 BFF 只选择 Remote 应用方法和身份策略,不注册任何模型接口。 + +#### KV Cache 影响 + +无直接影响;其触发的任何模型可见行为均由已挂载的 Host 能力负责。 + +## 已知限制与暂缓事项 + +- 能力集合由构建时显式导入的值固定确定;Client 不会在运行时发现 Host 中已启用的服务或 Remote 定义。 +- 若要增加能力,必须显式导入相应的 `/remote` 值并在此组合中挂载。 +- 在剩余 BFF 配置迁移到 `api-remotes` 之前,标准 Web Host 仍从旧 API Proxy 提供恢复默认值与 Agent scope 设置。 diff --git a/packages/client/remotes/package.json b/packages/api/remotes/package.json similarity index 64% rename from packages/client/remotes/package.json rename to packages/api/remotes/package.json index ba4e7b6a01..0a1e3ec71d 100644 --- a/packages/client/remotes/package.json +++ b/packages/api/remotes/package.json @@ -1,6 +1,6 @@ { - "name": "@deepseek-ai/dsh-client-remotes", - "description": "Platform-neutral assembly of explicitly selected Host Remote contributions", + "name": "@deepseek-ai/dsh-api-remotes", + "description": "Remote BFF assembly and Host Agent/Session lookup policy", "version": "0.0.1", "private": true, "type": "module", @@ -24,7 +24,7 @@ }, "dshClient": { "inject": [ - "@deepseek-ai/dsh-host-api-gateway" + "@deepseek-ai/dsh-api-gateway" ], "platform": "web", "immediately": true @@ -40,16 +40,25 @@ "lib/client.js", "lib/types/**/*.d.ts" ], + "dependencies": { + "@deepseek-ai/dsh-type-meta": "workspace:^" + }, "peerDependencies": { - "@deepseek-ai/dsh-host-api-gateway": "^0.0.1", + "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-goal": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-session-persistence": "^0.0.1", + "@deepseek-ai/dsh-typert-registry": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { - "@deepseek-ai/dsh-host-api-gateway": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-typert-registry": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/api/remotes/src/agent-lookup.ts b/packages/api/remotes/src/agent-lookup.ts new file mode 100644 index 0000000000..e3a5b27df8 --- /dev/null +++ b/packages/api/remotes/src/agent-lookup.ts @@ -0,0 +1,193 @@ +/** Host BFF policy for resolving Remote Agent and Session identities. */ + +import type { Context } from 'cordis' +import type { Agent, AgentOptions, AgentSetup } from '@deepseek-ai/dsh-agent' +import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-persistence' +import { TypeRTLookupFailure } from '@deepseek-ai/dsh-type-meta' +import type {} from '@deepseek-ai/dsh-typert-registry' + +/** Caller-facing failures preserved by the Gateway's RPC adapter. */ +export type ApiRemoteLookupError = + | { readonly code: 'agent-busy'; readonly message: string; readonly details: { readonly reason: string } } + | { readonly code: 'session-not-found'; readonly message: string; readonly details: { readonly sessionId: SessionId } } + | { readonly code: 'internal'; readonly message: string; readonly details: Record } + +/** Result of resolving one session identity to its live Agent. */ +export type ApiRemoteAgentResult = + | { readonly agent: Agent } + | { readonly error: ApiRemoteLookupError } + +/** Resume configuration supplied by the owning Host composition. */ +export interface ApiRemoteAgentOptions { + /** Per-Agent defaults used when a cold identity must resume. */ + readonly agentOptions?: AgentOptions + /** Host-specific Agent-scope composition completed before publication. */ + readonly setup?: AgentSetup +} + +/** Cold identity absent from the durable session store. */ +export class ApiRemoteSessionNotFound extends Error {} + +/** Session identity whose lifecycle belongs to subagent routing. */ +export class ApiRemoteSubagentSessionOwnership extends Error { + /** + * Construct the ownership fence. + * @param sessionId - identity reserved to subagent routing. + */ + constructor(readonly sessionId: SessionId) { + super(`session "${sessionId}" is a subagent session; use subagent delivery`) + } +} + +/** + * Test whether generic Host routing must leave an identity to subagent routing. + * @param ctx - Host Context carrying the live Agent registry. + * @param session - attached or live Session metadata. + * @param agent - live Agent when one is registered. + * @returns whether generic Remote and legacy API calls must reject the identity. + */ +export function hasApiRemoteSubagentOwner( + ctx: Context, + session: Pick, + agent: Agent | undefined, +): boolean { + if (session.header.origin === 'subagent') return true + const parentId = session.header.parentSession + if (parentId === undefined || agent === undefined) return false + const parent = ctx.agents.get(parentId) + return parent !== undefined && ctx.agents.isOwnedBy(agent.id, parent) +} + +/** + * Build the stable caller-facing ownership rejection. + * @param sessionId - identity reserved to subagent routing. + * @returns the existing `agent-busy` RPC shape. + */ +export function apiRemoteSubagentOwnershipError(sessionId: SessionId): ApiRemoteLookupError { + return { + code: 'agent-busy', + message: `session "${sessionId}" is owned by subagent routing`, + details: { reason: 'use subagent delivery for this child session' }, + } +} + +/** + * Inspect one cold served session without repairing, resuming, or publishing it. + * @param ctx - Host Context carrying the optional persistence provider. + * @param sessionId - durable identity to inspect. + * @returns detached metadata and events for a servable session. + * @throws {@link ApiRemoteSessionNotFound} when the identity has no project-backed session. + */ +export async function inspectApiRemoteSession( + ctx: Context, + sessionId: SessionId, +): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + const persistence = ctx.get('sessionPersistence') + if (persistence === undefined) { + throw new Error('session persistence is not configured (load a dsh-session-persistence backend)') + } + const meta = (await persistence.list()).find(candidate => candidate.id === sessionId) + if (meta === undefined || meta.cwd === undefined) { + throw new ApiRemoteSessionNotFound(`session "${sessionId}" not found`) + } + const inspected = await persistence.inspect(sessionId) + if (inspected.meta.cwd === undefined) { + throw new ApiRemoteSessionNotFound(`session "${sessionId}" not found`) + } + return { meta: inspected.meta, events: [...inspected.events] } +} + +/** + * Create the Host's shared Agent resolver and configure Agent/Session TypeRT lookups. + * Live Agents are reused, ordinary cold sessions resume once per identity, and + * subagent-owned identities retain the legacy `agent-busy` fence. + * @param ctx - owning Host Context. + * @param options - defaults and Agent-scope setup used only for cold resume. + * @returns resolver shared by legacy API Proxy methods and TypeRT lookups. + */ +export function createApiRemoteAgentResolver( + ctx: Context, + options: ApiRemoteAgentOptions, +): (sessionId: SessionId) => Promise { + const resumes = new Map>() + + const fencedLiveAgent = (sessionId: SessionId): ApiRemoteAgentResult | undefined => { + const live = ctx.agents.get(sessionId) + if (live === undefined) return undefined + if (hasApiRemoteSubagentOwner(ctx, live.session, live)) { + return { error: apiRemoteSubagentOwnershipError(sessionId) } + } + return { agent: live } + } + + const agentFor = async (sessionId: SessionId): Promise => { + const fenced = fencedLiveAgent(sessionId) + if (fenced !== undefined) return fenced + const attached = ctx.sessions.get(sessionId) + if (attached !== undefined && hasApiRemoteSubagentOwner(ctx, attached, undefined)) { + return { error: apiRemoteSubagentOwnershipError(sessionId) } + } + let resume = resumes.get(sessionId) + if (resume === undefined) { + resume = (async () => { + try { + const inspected = await inspectApiRemoteSession(ctx, sessionId) + if (hasApiRemoteSubagentOwner(ctx, { header: inspected.meta }, undefined)) { + throw new ApiRemoteSubagentSessionOwnership(sessionId) + } + const publishedSession = ctx.sessions.get(sessionId) + const publishedAgent = ctx.agents.get(sessionId) + if (publishedSession !== undefined + && hasApiRemoteSubagentOwner(ctx, publishedSession, publishedAgent)) { + throw new ApiRemoteSubagentSessionOwnership(sessionId) + } + const handle = await ctx.agents.resume({ + resumeSessionId: sessionId, + ...options.agentOptions === undefined ? {} : { agentOptions: options.agentOptions }, + ...options.setup === undefined ? {} : { setup: options.setup }, + }) + return handle.agent + } finally { + resumes.delete(sessionId) + } + })() + resumes.set(sessionId, resume) + } + try { + return { agent: await resume } + } catch (error: unknown) { + if (error instanceof ApiRemoteSessionNotFound) { + return { error: { code: 'session-not-found', message: error.message, details: { sessionId } } } + } + if (error instanceof ApiRemoteSubagentSessionOwnership) { + return { error: apiRemoteSubagentOwnershipError(error.sessionId) } + } + const fenced = fencedLiveAgent(sessionId) + if (fenced !== undefined) return fenced + const attached = ctx.sessions.get(sessionId) + if (attached !== undefined && hasApiRemoteSubagentOwner(ctx, attached, undefined)) { + return { error: apiRemoteSubagentOwnershipError(sessionId) } + } + return { + error: { + code: 'internal', + message: `resume failed for session "${sessionId}": ${String(error)}`, + details: {}, + }, + } + } + } + + ctx.inject(['typert'], (typeCtx) => { + const resolveAgent = async (sessionId: SessionId): Promise => { + const found = await agentFor(sessionId) + if ('error' in found) throw new TypeRTLookupFailure(found.error) + return found.agent + } + typeCtx.typert.lookups.configure('agent', resolveAgent) + typeCtx.typert.lookups.configure('session', async sessionId => (await resolveAgent(sessionId)).session) + }) + + return agentFor +} diff --git a/packages/client/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts similarity index 64% rename from packages/client/remotes/src/client/index.ts rename to packages/api/remotes/src/client/index.ts index 09757b5e9e..1bc36b62ee 100644 --- a/packages/client/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -1,12 +1,19 @@ /** Platform-neutral assembly of generated Host Remote contributions. */ import type { Context } from 'cordis' -import type {} from '@deepseek-ai/dsh-host-api-gateway/client' import goalsRemote from '@deepseek-ai/dsh-goal/remote' +import type { TypeRTClientApi } from '@deepseek-ai/dsh-type-meta' -export type { ClientApi } from '@deepseek-ai/dsh-host-api-gateway/client' +export type { TypeRTClientApi as ClientApi } from '@deepseek-ai/dsh-type-meta' export type {} from '@deepseek-ai/dsh-goal/remote' +declare module 'cordis' { + interface Context { + /** Generated direct Remote namespaces selected by this Client assembly. */ + api: TypeRTClientApi + } +} + /** Required service: the typed Client API contribution mount. */ export const inject = ['api'] diff --git a/packages/api/remotes/src/index.ts b/packages/api/remotes/src/index.ts new file mode 100644 index 0000000000..4cd70f4a78 --- /dev/null +++ b/packages/api/remotes/src/index.ts @@ -0,0 +1,18 @@ +/** Host BFF entry and Loader shell for the Remote contribution assembly. */ + +export { + ApiRemoteSessionNotFound, + ApiRemoteSubagentSessionOwnership, + apiRemoteSubagentOwnershipError, + createApiRemoteAgentResolver, + hasApiRemoteSubagentOwner, + inspectApiRemoteSession, +} from './agent-lookup.ts' +export type { + ApiRemoteAgentOptions, + ApiRemoteAgentResult, + ApiRemoteLookupError, +} from './agent-lookup.ts' + +/** Host plugin body; the selected contributions mount only in Client environments. */ +export function apply(): void {} diff --git a/packages/client/remotes/src/invariant.ts b/packages/api/remotes/src/invariant.ts similarity index 70% rename from packages/client/remotes/src/invariant.ts rename to packages/api/remotes/src/invariant.ts index 1a6b0ba237..3310bed11f 100644 --- a/packages/client/remotes/src/invariant.ts +++ b/packages/api/remotes/src/invariant.ts @@ -1,17 +1,17 @@ -/** Package-owned invariant companion for `@deepseek-ai/dsh-client-remotes`. */ +/** Package-owned invariant companion for `@deepseek-ai/dsh-api-remotes`. */ /* jscpd:ignore-start */ import type { Context } from 'cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' -const PACKAGE_NAME = '@deepseek-ai/dsh-client-remotes' +const PACKAGE_NAME = '@deepseek-ai/dsh-api-remotes' /** Cordis companion plugin name. */ -export const name = 'client-remotes-invariant' +export const name = 'api-remotes-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** No runtime invariant: the API service owns contribution and method lifecycle atomically. */ +/** No runtime invariant: TypeRT and the Agent/Session registries own the observed relationships. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/client/remotes/tests/built-lib.e2e.ts b/packages/api/remotes/tests/built-lib.e2e.ts similarity index 95% rename from packages/client/remotes/tests/built-lib.e2e.ts rename to packages/api/remotes/tests/built-lib.e2e.ts index 0cee3eb245..b8f6c81e98 100644 --- a/packages/client/remotes/tests/built-lib.e2e.ts +++ b/packages/api/remotes/tests/built-lib.e2e.ts @@ -17,13 +17,13 @@ const artifactUrl = (path: string): string => pathToFileURL(artifact(path)).href const requiredArtifacts = [ 'packages/client/connection/lib/client.js', 'packages/client/connection/lib/index.js', - 'packages/client/remotes/lib/client.js', + 'packages/api/remotes/lib/client.js', 'packages/core/agent/lib/index.js', 'packages/core/session/lib/index.js', 'packages/goal/goal/lib/index.js', 'packages/goal/goal/lib/typert.host.js', - 'packages/host/api-gateway/lib/client.js', - 'packages/host/api-gateway/lib/index.js', + 'packages/api/gateway/lib/client.js', + 'packages/api/gateway/lib/index.js', 'packages/typert/registry/lib/client.js', 'packages/typert/registry/lib/index.js', ].every(path => existsSync(artifact(path))) @@ -32,15 +32,15 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { it('runs root and Agent-scoped calls through generated bundles and real HTTP', async () => { const urls = Object.fromEntries(Object.entries({ agent: 'packages/core/agent/lib/index.js', - apiGatewayClient: 'packages/host/api-gateway/lib/client.js', - apiGatewayHost: 'packages/host/api-gateway/lib/index.js', + apiGatewayClient: 'packages/api/gateway/lib/client.js', + apiGatewayHost: 'packages/api/gateway/lib/index.js', connectionClient: 'packages/client/connection/lib/client.js', connectionHost: 'packages/client/connection/lib/index.js', goal: 'packages/goal/goal/lib/index.js', goalTypert: 'packages/goal/goal/lib/typert.host.js', registryClient: 'packages/typert/registry/lib/client.js', registryHost: 'packages/typert/registry/lib/index.js', - remotesClient: 'packages/client/remotes/lib/client.js', + remotesClient: 'packages/api/remotes/lib/client.js', session: 'packages/core/session/lib/index.js', }).map(([key, path]) => [key, artifactUrl(path)])) const script = ` @@ -131,8 +131,8 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { for (const id of [ '@deepseek-ai/dsh-typert-registry', '@deepseek-ai/dsh-client-connection', - '@deepseek-ai/dsh-host-api-gateway', - '@deepseek-ai/dsh-client-remotes', + '@deepseek-ai/dsh-api-gateway', + '@deepseek-ai/dsh-api-remotes', ]) { const plugin = instantiate(id) await client.plugin({ inject: plugin.inject, apply: plugin.apply }) diff --git a/packages/client/remotes/tsconfig.json b/packages/api/remotes/tsconfig.json similarity index 63% rename from packages/client/remotes/tsconfig.json rename to packages/api/remotes/tsconfig.json index c99a5fce19..148804dc0f 100644 --- a/packages/client/remotes/tsconfig.json +++ b/packages/api/remotes/tsconfig.json @@ -12,7 +12,19 @@ "path": "../../../vendor/cordis" }, { - "path": "../../host/api-gateway" + "path": "../../core/agent" + }, + { + "path": "../../core/session" + }, + { + "path": "../../session-persistence/session-persistence" + }, + { + "path": "../../typert/type-meta" + }, + { + "path": "../../typert/registry" }, { "path": "../../ui/commands" diff --git a/packages/api/remotes/tsdown.config.ts b/packages/api/remotes/tsdown.config.ts new file mode 100644 index 0000000000..287b2c7975 --- /dev/null +++ b/packages/api/remotes/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../../client/tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-api-remotes', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index 0b1cc43a50..11b23c27be 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -41,7 +41,7 @@ name: '@deepseek-ai/dsh-typert-loader' - id: typert-gateway - name: '@deepseek-ai/dsh-host-api-gateway' + name: '@deepseek-ai/dsh-api-gateway' - id: session-title name: '@deepseek-ai/dsh-session-title' diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 2ec17d9c66..9895d8834c 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -49,7 +49,7 @@ "@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-goal-session": "workspace:^", - "@deepseek-ai/dsh-host-api-gateway": "workspace:^", + "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 001c43948d..dc3212a36c 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -124,8 +124,8 @@ - id: connection name: '@deepseek-ai/dsh-client-connection' - - id: client-remotes - name: '@deepseek-ai/dsh-client-remotes' + - id: api-remotes + name: '@deepseek-ai/dsh-api-remotes' - id: client-runtime name: '@deepseek-ai/dsh-client-runtime' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 89b5e8e2a7..4e2d9cf70b 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -36,7 +36,7 @@ "@deepseek-ai/dsh-client-hmr": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", - "@deepseek-ai/dsh-client-remotes": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-command": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", diff --git a/packages/client/remotes/README.md b/packages/client/remotes/README.md deleted file mode 100644 index e29188b8e3..0000000000 --- a/packages/client/remotes/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# @deepseek-ai/dsh-client-remotes - -English | [中文](README.zh.md) - -Platform-neutral Client facade for Host Remote capabilities selected by this application. Its Client entry imports generated `/remote` artifacts as runtime values, mounts each contribution through `ctx.api`, and re-exports their declaration merges. Client business packages depend on this facade rather than the Host API Gateway or individual Remote runtime entries. - -The current assembly mounts only the Goal Remote contribution. Cordis effect ownership withdraws every contribution when this assembly unloads, while the Client face of `@deepseek-ai/dsh-host-api-gateway` owns descriptor validation, concrete root and scoped methods, invocation, and cancellation. - -This package contains no transport or Host discovery logic. It can be reused by Web or a future TUI Client that provides the same React-free `ctx.api` contract. - -## Model Experience - -None, as this Client assembly selects Remote application methods and registers no model surface. - -#### KV Cache effect - -No direct effect; mounted Host capabilities own any model-visible behavior they trigger. - -## Known Limitations and Deferred Work - -- The capability set is fixed by explicit build-time value imports; the Client does not discover the Host's active Services or Remote definitions at runtime. -- Additional capabilities require an explicit `/remote` value import and mount in this assembly. diff --git a/packages/client/remotes/README.zh.md b/packages/client/remotes/README.zh.md deleted file mode 100644 index e6425ab190..0000000000 --- a/packages/client/remotes/README.zh.md +++ /dev/null @@ -1,22 +0,0 @@ -# @deepseek-ai/dsh-client-remotes - -[English](README.md) | 中文 - -为本应用选定的 Host Remote 能力提供平台无关的 Client 外观。其 Client 入口以运行时值形式导入生成的 `/remote` 产物,通过 `ctx.api` 挂载每项贡献,并重新导出对应的声明合并。Client 业务包依赖此外观,而不依赖 Host API Gateway 或单独的 Remote 运行时入口。 - -当前组合仅挂载 Goal Remote 贡献。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-host-api-gateway` 的 Client 侧负责描述符校验、具体的根级方法和作用域方法、调用与取消。 - -本包不包含传输逻辑或 Host 发现逻辑。Web 和未来的 TUI Client 只要提供同一份不依赖 React 的 `ctx.api` 契约,均可复用本包。 - -## 模型体验 - -无,因为此 Client 组合只选择应用的 Remote 方法,不注册任何模型接口。 - -#### KV Cache 影响 - -无直接影响;其触发的任何模型可见行为均由已挂载的 Host 能力负责。 - -## 已知限制与暂缓事项 - -- 能力集合由构建时显式导入的值固定确定;Client 不会在运行时发现 Host 中已启用的服务或 Remote 定义。 -- 若要增加能力,必须显式导入相应的 `/remote` 值并在此组合中挂载。 diff --git a/packages/client/remotes/src/index.ts b/packages/client/remotes/src/index.ts deleted file mode 100644 index c8c4ff20be..0000000000 --- a/packages/client/remotes/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** Host Loader entry for the Client Remote contribution assembly. */ - -/** Host plugin body; the selected contributions mount only in Client environments. */ -export function apply(): void {} diff --git a/packages/client/remotes/tsdown.config.ts b/packages/client/remotes/tsdown.config.ts deleted file mode 100644 index 20fa098462..0000000000 --- a/packages/client/remotes/tsdown.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { clientBundle } from '../tsdown.client.ts' - -export default clientBundle('@deepseek-ai/dsh-client-remotes', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index cc51aa772d..711510b705 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -25,7 +25,7 @@ "dshClient": { "inject": [ "@deepseek-ai/dsh-client-connection", - "@deepseek-ai/dsh-client-remotes", + "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-typert-registry" ], "platform": "web", @@ -49,14 +49,14 @@ }, "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-client-remotes": "^0.0.1", + "@deepseek-ai/dsh-api-remotes": "^0.0.1", "@deepseek-ai/dsh-type-meta": "^0.0.1", "@deepseek-ai/dsh-typert-registry": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-client-remotes": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-type-meta": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index f1efd6a65d..a9d2bb0d7d 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -1,7 +1,7 @@ /** Browser runtime services for slots, sessions, workspaces, and connection-stream delivery. */ import type { Context } from 'cordis' import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client' -import type {} from '@deepseek-ai/dsh-client-remotes/client' +import type {} from '@deepseek-ai/dsh-api-remotes/client' import type { TypeRTContext } from '@deepseek-ai/dsh-type-meta' import type { MaybeSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import { SlotsService } from './slots.ts' diff --git a/packages/client/runtime/tsconfig.json b/packages/client/runtime/tsconfig.json index 85ba61d41a..efbf7c26d7 100644 --- a/packages/client/runtime/tsconfig.json +++ b/packages/client/runtime/tsconfig.json @@ -21,7 +21,7 @@ "path": "../connection" }, { - "path": "../remotes" + "path": "../../api/remotes" }, { "path": "../../host/apiproxy" diff --git a/packages/client/ui-goal/package.json b/packages/client/ui-goal/package.json index 4c26405bd8..63410707a0 100644 --- a/packages/client/ui-goal/package.json +++ b/packages/client/ui-goal/package.json @@ -25,7 +25,7 @@ "dshClient": { "inject": [ "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-remotes", + "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-conversation" ], @@ -38,7 +38,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-client-locale": "^0.0.1", - "@deepseek-ai/dsh-client-remotes": "^0.0.1", + "@deepseek-ai/dsh-api-remotes": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", @@ -50,7 +50,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-remotes": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", diff --git a/packages/client/ui-goal/src/client/index.ts b/packages/client/ui-goal/src/client/index.ts index 8fcfd292d2..2e49d5b6b8 100644 --- a/packages/client/ui-goal/src/client/index.ts +++ b/packages/client/ui-goal/src/client/index.ts @@ -10,7 +10,7 @@ */ import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' // Type-only: pulls the generated Remote API and ctx.api merge through the Client assembly boundary. -import type {} from '@deepseek-ai/dsh-client-remotes/client' +import type {} from '@deepseek-ai/dsh-api-remotes/client' // Type-only: pulls the ui-conversation SlotMap merge (the input.dock entry). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). diff --git a/packages/client/ui-goal/tsconfig.json b/packages/client/ui-goal/tsconfig.json index 2bb4070b18..263dfceb26 100644 --- a/packages/client/ui-goal/tsconfig.json +++ b/packages/client/ui-goal/tsconfig.json @@ -15,7 +15,7 @@ "path": "../locale" }, { - "path": "../remotes" + "path": "../../api/remotes" }, { "path": "../runtime" diff --git a/packages/host/api-gateway/tsdown.config.ts b/packages/host/api-gateway/tsdown.config.ts deleted file mode 100644 index 1f95a1f2c5..0000000000 --- a/packages/host/api-gateway/tsdown.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { clientBundle } from '../../client/tsdown.client.ts' - -export default clientBundle('@deepseek-ai/dsh-host-api-gateway', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index ce740a025f..91d03b4448 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -39,6 +39,7 @@ "license": "BSD-3-Clause", "dependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", @@ -56,8 +57,6 @@ "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-type-meta": "workspace:^", - "@deepseek-ai/dsh-typert-registry": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", @@ -71,6 +70,8 @@ "devDependencies": { "@deepseek-ai/dsh-storage": "workspace:^", "@deepseek-ai/dsh-storage-domain": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", + "@deepseek-ai/dsh-typert-registry": "workspace:^", "cordis": "^4.0.0-rc.7", "@deepseek-ai/dsh-invariants": "workspace:^" } diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index f2c199feca..e4b715a0c4 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -19,9 +19,6 @@ import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-se import { SubagentError } from '@deepseek-ai/dsh-subagent' import type { SubagentListEntry as CatalogSubagentListEntry } from '@deepseek-ai/dsh-subagent' import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' -import { TypeRTLookupFailure } from '@deepseek-ai/dsh-type-meta' -// Type-only: resolves the optional `ctx.typert` lookup-policy composition. -import type {} from '@deepseek-ai/dsh-typert-registry' import { workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId, WorkspaceMoveInvalidError, WorkspaceUnknownSessionError, @@ -72,6 +69,14 @@ import type { } from '@deepseek-ai/dsh-user-interaction' import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction' import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker' +import { + ApiRemoteSessionNotFound as SessionNotFound, + ApiRemoteSubagentSessionOwnership as SubagentSessionOwnership, + apiRemoteSubagentOwnershipError, + createApiRemoteAgentResolver, + hasApiRemoteSubagentOwner, + inspectApiRemoteSession, +} from '@deepseek-ai/dsh-api-remotes' import { openNativePath, openNativeTextFile } from './native-path-opener.ts' /** Page size when history is called without maxMessages. */ @@ -666,19 +671,6 @@ async function catalogChild( } } -/** - * Thrown by the cold-resume path when the id names no servable session - * (absent from the store, or a pre-project legacy log without a cwd). - */ -class SessionNotFound extends Error {} - -/** Session identity whose lifecycle belongs to subagent routing, not generic Host resume. */ -class SubagentSessionOwnership extends Error { - constructor(readonly sessionId: SessionId) { - super(`session "${sessionId}" is a subagent session; use subagent delivery`) - } -} - /** Requested identity already belongs to a session with another project cwd. */ class SessionCwdConflict extends Error { constructor( @@ -752,8 +744,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } type WebLlmTargetRef = AgentLlmTargetRef & { current: AgentLlmTarget } const targets = new WeakMap() - /** Implicit resume of cold sessions, deduplicating concurrent calls (follows the jsonrpc sessionCreations precedent). */ - const resumes = new Map>() /** Client-chosen identity creation/resume, deduplicated across concurrent retries. */ const sessionCreations = new Map>() /** Serializes path ownership and explicit title checks with Workspace mutations. */ @@ -811,6 +801,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro targetFor(agent) } + const hasSubagentOwner = ( + session: Pick, + agent: Agent | undefined, + ): boolean => hasApiRemoteSubagentOwner(ctx, session, agent) + const subagentOwnershipError = (sessionId: SessionId): RpcError => + apiRemoteSubagentOwnershipError(sessionId) + const inspectServable = (sessionId: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> => + inspectApiRemoteSession(ctx, sessionId) + const agentFor = createApiRemoteAgentResolver(ctx, { agentOptions, setup: installTarget }) + /** Send one transient frame to every connected mux consumer. */ function broadcast(payload: MuxFrame): void { const envelope = frame(payload) @@ -992,131 +992,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }) } - /** - * Generic Host interaction cannot claim a durably classified subagent - * (`origin: 'subagent'` in the header) or an Agent runtime-owned by its - * live parent. - */ - function hasSubagentOwner( - session: Pick, - agent: Agent | undefined, - ): boolean { - if (session.header.origin === 'subagent') return true - const parentId = session.header.parentSession - if (parentId === undefined || agent === undefined) return false - const parent = ctx.agents.get(parentId) - return parent !== undefined && ctx.agents.isOwnedBy(agent.id, parent) - } - - /** Stable generic-Host error for an identity reserved to subagent routing. */ - function subagentOwnershipError(sessionId: SessionId): RpcError { - return { - code: 'agent-busy', - message: `session "${sessionId}" is owned by subagent routing`, - details: { reason: 'use subagent delivery for this child session' }, - } - } - - /** Inspect one cold served session without repairing, resuming, or publishing it. */ - async function inspectServable(sessionId: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { - const persistence = ctx.get('sessionPersistence') - if (persistence === undefined) { - throw new Error('session persistence is not configured (load a dsh-session-persistence backend)') - } - const meta = (await persistence.list()).find(m => m.id === sessionId) - if (meta === undefined || meta.cwd === undefined) throw new SessionNotFound(`session "${sessionId}" not found`) - const inspected = await persistence.inspect(sessionId) - if (inspected.meta.cwd === undefined) throw new SessionNotFound(`session "${sessionId}" not found`) - return { meta: inspected.meta, events: [...inspected.events] } - } - - /** - * Resolve one live registered identity through the subagent-ownership - * fence: subagent-owned agents answer `agent-busy`, plain agents pass. - * Fences the live agent's own session rather than trusting a - * "registered ⇒ attached-store" invariant — a registered subagent whose - * session is ever absent from the attached store must still not be handed - * out through generic Host routing. `undefined` means no live agent. - */ - function fencedLiveAgent(sessionId: SessionId): { agent: Agent } | { error: RpcError } | undefined { - const live = ctx.agents.get(sessionId) - if (live === undefined) return undefined - if (hasSubagentOwner(live.session, live)) return { error: subagentOwnershipError(sessionId) } - return { agent: live } - } - - async function agentFor(sessionId: SessionId): Promise<{ agent: Agent } | { error: RpcError }> { - const fenced = fencedLiveAgent(sessionId) - if (fenced !== undefined) return fenced - const attached = ctx.sessions.get(sessionId) - if (attached !== undefined && hasSubagentOwner(attached, undefined)) { - return { error: subagentOwnershipError(sessionId) } - } - let resume = resumes.get(sessionId) - if (resume === undefined) { - resume = (async () => { - try { - const inspected = await inspectServable(sessionId) - if (hasSubagentOwner({ header: inspected.meta }, undefined)) { - throw new SubagentSessionOwnership(sessionId) - } - const publishedSession = ctx.sessions.get(sessionId) - const publishedAgent = ctx.agents.get(sessionId) - if (publishedSession !== undefined && hasSubagentOwner(publishedSession, publishedAgent)) { - throw new SubagentSessionOwnership(sessionId) - } - const handle = await ctx.agents.resume({ - resumeSessionId: sessionId, - agentOptions: agentOptions(), - setup: installTarget, - }) - return handle.agent - } finally { - resumes.delete(sessionId) - } - })() - resumes.set(sessionId, resume) - } - try { - return { agent: await resume } - } catch (error: unknown) { - if (error instanceof SessionNotFound) { - return { error: { code: 'session-not-found', message: error.message, details: { sessionId } } } - } - if (error instanceof SubagentSessionOwnership) { - return { error: subagentOwnershipError(error.sessionId) } - } - // A concurrent publish can win the identity between the pre-resume - // re-check and `ctx.agents.resume` publication; the ID-collision - // rejection falls through here. Mirror ensureSession's `.catch` in - // full: classify a subagent-owned winner into the stable ownership - // error, and hand a clean plain-agent winner straight back. - const fenced = fencedLiveAgent(sessionId) - if (fenced !== undefined) return fenced - const attached = ctx.sessions.get(sessionId) - if (attached !== undefined && hasSubagentOwner(attached, undefined)) { - return { error: subagentOwnershipError(sessionId) } - } - // The internal details slot is contractually {}; the reason rides the message. - return { error: { code: 'internal', message: `resume failed for session "${sessionId}": ${String(error)}`, details: {} } } - } - } - - // Remote object parameters use the same identity policy as API Proxy methods: - // ordinary cold sessions resume once, while subagent-owned identities retain - // their stable caller-facing rejection. The provider packages continue to - // own wire declarations and live-only defaults; this Host composition owns - // the broader lookup policy. - ctx.inject(['typert'], (typeCtx) => { - const resolveAgent = async (sessionId: SessionId): Promise => { - const found = await agentFor(sessionId) - if ('error' in found) throw new TypeRTLookupFailure(found.error) - return found.agent - } - typeCtx.typert.lookups.configure('agent', resolveAgent) - typeCtx.typert.lookups.configure('session', async sessionId => (await resolveAgent(sessionId)).session) - }) - type SessionReadState = { id: SessionId header: SessionHeader diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index 23c170f4fd..912f2cd794 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../../vendor/schemastery" }, + { + "path": "../../api/remotes" + }, { "path": "../../util/brand" }, @@ -38,12 +41,6 @@ { "path": "../../core/tools" }, - { - "path": "../../typert/type-meta" - }, - { - "path": "../../typert/registry" - }, { "path": "../../session-persistence/session-persistence" }, diff --git a/packages/typert/type-meta/src/index.ts b/packages/typert/type-meta/src/index.ts index 7ded29fa4a..1418c9d7f2 100644 --- a/packages/typert/type-meta/src/index.ts +++ b/packages/typert/type-meta/src/index.ts @@ -41,6 +41,7 @@ export type { InvocationDescriptor, InvocationParameterDescriptor, InvocationSourceLocation, + TypeRTClientApi, TypeRTClientContextBinder, TypeRTCodec, TypeRTContext, diff --git a/packages/typert/type-meta/src/types.ts b/packages/typert/type-meta/src/types.ts index 7831c08e37..b65690115f 100644 --- a/packages/typert/type-meta/src/types.ts +++ b/packages/typert/type-meta/src/types.ts @@ -176,6 +176,16 @@ export interface TypeRTRemoteContribution { readonly descriptors: readonly InvocationDescriptor[] } +/** Client API capability implemented by the Gateway and consumed by Remote assemblies. */ +export interface TypeRTClientApi extends TypeRTRemoteNamespaceMap { + /** + * Mount one generated Host-for-Client contribution in the caller's fiber. + * @param contribution - explicitly selected Remote package artifact. + * @returns disposer withdrawing descriptors and concrete methods together. + */ + mount(contribution: TypeRTRemoteContribution): TypeRTDisposer +} + /** * Resolve one validated wire identity, synchronously or asynchronously. * @param id - validated wire identity. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e0adfb22c1..bd4ff1ea14 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -621,6 +621,59 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/api/gateway: + dependencies: + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../../typert/type-meta + devDependencies: + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../../client/connection + '@deepseek-ai/dsh-host-webserver': + specifier: workspace:^ + version: link:../../host/webserver + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-typert-registry': + specifier: workspace:^ + version: link:../../typert/registry + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + zod: + specifier: ^4.4.3 + version: 4.4.3 + + packages/api/remotes: + dependencies: + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../../typert/type-meta + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-goal': + specifier: workspace:^ + version: link:../../goal/goal + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence + '@deepseek-ai/dsh-typert-registry': + specifier: workspace:^ + version: link:../../typert/registry + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/bash/bash: devDependencies: '@deepseek-ai/dsh-invariants': @@ -877,6 +930,9 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop + '@deepseek-ai/dsh-api-gateway': + specifier: workspace:^ + version: link:../../api/gateway '@deepseek-ai/dsh-bash-env': specifier: workspace:^ version: link:../../bash/bash-env @@ -916,9 +972,6 @@ importers: '@deepseek-ai/dsh-goal-session': specifier: workspace:^ version: link:../../goal/goal-session - '@deepseek-ai/dsh-host-api-gateway': - specifier: workspace:^ - version: link:../../host/api-gateway '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1119,6 +1172,9 @@ importers: packages/bundle/web-app: dependencies: + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../../client/connection @@ -1131,9 +1187,6 @@ importers: '@deepseek-ai/dsh-client-modules': specifier: workspace:^ version: link:../../client/modules - '@deepseek-ai/dsh-client-remotes': - specifier: workspace:^ - version: link:../../client/remotes '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../../client/runtime @@ -1348,21 +1401,6 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis - packages/client/remotes: - devDependencies: - '@deepseek-ai/dsh-goal': - specifier: workspace:^ - version: link:../../goal/goal - '@deepseek-ai/dsh-host-api-gateway': - specifier: workspace:^ - version: link:../../host/api-gateway - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis - packages/client/runtime: dependencies: '@deepseek-ai/dsh-client-connection': @@ -1405,9 +1443,9 @@ importers: specifier: ~4.4.7 version: 4.4.7(@types/react@18.3.31)(immer@10.2.0)(react@18.3.1) devDependencies: - '@deepseek-ai/dsh-client-remotes': + '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ - version: link:../remotes + version: link:../../api/remotes '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -1617,12 +1655,12 @@ importers: packages/client/ui-goal: devDependencies: + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-remotes': - specifier: workspace:^ - version: link:../remotes '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime @@ -3757,36 +3795,14 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis - packages/host/api-gateway: - dependencies: - '@deepseek-ai/dsh-type-meta': - specifier: workspace:^ - version: link:../../typert/type-meta - devDependencies: - '@deepseek-ai/dsh-client-connection': - specifier: workspace:^ - version: link:../../client/connection - '@deepseek-ai/dsh-host-webserver': - specifier: workspace:^ - version: link:../webserver - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - '@deepseek-ai/dsh-typert-registry': - specifier: workspace:^ - version: link:../../typert/registry - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis - zod: - specifier: ^4.4.3 - version: 4.4.3 - packages/host/apiproxy: dependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -3838,12 +3854,6 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - '@deepseek-ai/dsh-type-meta': - specifier: workspace:^ - version: link:../../typert/type-meta - '@deepseek-ai/dsh-typert-registry': - specifier: workspace:^ - version: link:../../typert/registry '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../../ui/user-approval @@ -3869,6 +3879,12 @@ importers: '@deepseek-ai/dsh-storage-domain': specifier: workspace:^ version: link:../../storage/storage-domain + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../../typert/type-meta + '@deepseek-ai/dsh-typert-registry': + specifier: workspace:^ + version: link:../../typert/registry cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 088329e83d..54581a8605 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -289,7 +289,7 @@ export const TYPE_LINK_EXEMPTIONS: Readonly> = { ThemeTokens: 'service-local token dictionary is owned by packages/client/ui-theme/src/index.ts', Translate: 'service-local bound translator is owned by packages/client/i18n/src/index.ts', InvariantRegistration: 'service-local lifecycle handle is owned by packages/support/invariants/README.md', - InvokeRemoteRequest: 'gateway invocation contract is owned by packages/host/api-gateway/README.md', + InvokeRemoteRequest: 'gateway invocation contract is owned by packages/api/gateway/README.md', PresetOption: 'deployment menu metadata is owned by packages/ui/permission/README.md', PresetSpec: 'deployment preset composition is owned by packages/ui/permission/README.md', KnobState: 'projection unit state shape is owned by packages/ui/permission/README.md', diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 6d6a76e476..9adc3d9768 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -601,7 +601,7 @@ function builtBinSmokeGate(needs: string[] = ['build']): Gate { 'packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts', 'packages/subagent/subagent-codex/tests/loader-composition.e2e.ts', 'packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts', - 'packages/client/remotes/tests/built-lib.e2e.ts', + 'packages/api/remotes/tests/built-lib.e2e.ts', // The worker-entry packages' built bundles: the only automated proof // that lib/index.js resolves its sibling lib/worker.cjs under plain node // (the e2e lane runs unbuilt, so these files self-skip there). diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index edadc3f134..ecb13f167c 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1538,22 +1538,22 @@ { "doc": "docs/core-data-structures/typert.md", "symbol": "InvokeRemoteRequest", - "source": "packages/host/api-gateway/src/types.ts" + "source": "packages/api/gateway/src/types.ts" }, { "doc": "docs/core-data-structures/typert.md", "symbol": "TypertGatewayErrorCode", - "source": "packages/host/api-gateway/src/types.ts" + "source": "packages/api/gateway/src/types.ts" }, { "doc": "docs/core-data-structures/typert.md", "symbol": "TypertGateway", - "source": "packages/host/api-gateway/src/types.ts" + "source": "packages/api/gateway/src/types.ts" }, { "doc": "docs/core-data-structures/typert.md", - "symbol": "ClientApi", - "source": "packages/host/api-gateway/src/client/index.ts" + "symbol": "TypeRTClientApi", + "source": "packages/typert/type-meta/src/types.ts" } ] } diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 78745dbed1..e131d6c403 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -57,7 +57,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/web-react': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/schema-form': { kind: 'none', reason: 'Browser-side form-rendering library; registers no model surface.' }, 'packages/client/connection': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, - 'packages/client/remotes': { kind: 'none', reason: 'Client-side Remote assembly; selected business methods own any model-visible effect.' }, + 'packages/api/remotes': { kind: 'none', reason: 'The Remote BFF selects business methods and identity policy; selected services own any model-visible effect.' }, 'packages/client/runtime': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-layout': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-sidebar': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, @@ -126,7 +126,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/support/loader-smoke': { kind: 'none', reason: 'The test harness observes child-process streams without changing live requests.' }, 'packages/support/llm-mock-server': { kind: 'none', reason: 'The test server substitutes provider wire behavior without invoking a real model.' }, 'packages/support/llm-replay': { kind: 'none', reason: 'The keyless adapter invokes no provider model.' }, - 'packages/host/api-gateway': { kind: 'none', reason: 'Remote dispatch infrastructure; invoked business methods own any model-visible effect.' }, + 'packages/api/gateway': { kind: 'none', reason: 'Remote dispatch infrastructure; invoked business methods own any model-visible effect.' }, 'packages/typert/type-meta': { kind: 'none', reason: 'Compiler-independent Remote protocol declarations; registers no model surface.' }, 'packages/typert/generator': { kind: 'none', reason: 'The build-time generator runs outside any agent runtime and touches no model request.' }, 'packages/tasks/tasks': { kind: 'indirect', reason: 'Producer and control-surface plugins own all model rendering over the task registry.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index ce4fca35f9..b9907348fa 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -41,10 +41,10 @@ "@deepseek-ai/dsh-invariants": ["./packages/support/invariants/src/index.ts"], "@deepseek-ai/dsh-typert-registry": ["./packages/typert/registry/src/index.ts"], "@deepseek-ai/dsh-typert-registry/client": ["./packages/typert/registry/src/client/index.ts"], - "@deepseek-ai/dsh-host-api-gateway": ["./packages/host/api-gateway/src/index.ts"], - "@deepseek-ai/dsh-host-api-gateway/client": ["./packages/host/api-gateway/src/client/index.ts"], - "@deepseek-ai/dsh-host-api-gateway/invariant": ["./packages/host/api-gateway/src/invariant.ts"], - "@deepseek-ai/dsh-host-api-gateway/types": ["./packages/host/api-gateway/src/types.ts"], + "@deepseek-ai/dsh-api-gateway": ["./packages/api/gateway/src/index.ts"], + "@deepseek-ai/dsh-api-gateway/client": ["./packages/api/gateway/src/client/index.ts"], + "@deepseek-ai/dsh-api-gateway/invariant": ["./packages/api/gateway/src/invariant.ts"], + "@deepseek-ai/dsh-api-gateway/types": ["./packages/api/gateway/src/types.ts"], "@deepseek-ai/dsh-type-meta": ["./packages/typert/type-meta/src/index.ts"], "@deepseek-ai/dsh-type-meta/types": ["./packages/typert/type-meta/src/types.ts"], "@deepseek-ai/dsh-typert-loader": ["./packages/typert/loader/src/index.ts"], @@ -151,8 +151,8 @@ "@deepseek-ai/dsh-client-schema-form/invariant": ["./packages/client/schema-form/src/invariant.ts"], "@deepseek-ai/dsh-client-web-react": ["./packages/client/web-react/src"], "@deepseek-ai/dsh-client-connection": ["./packages/client/connection/src"], - "@deepseek-ai/dsh-client-remotes": ["./packages/client/remotes/src"], - "@deepseek-ai/dsh-client-remotes/client": ["./packages/client/remotes/src/client/index.ts"], + "@deepseek-ai/dsh-api-remotes": ["./packages/api/remotes/src"], + "@deepseek-ai/dsh-api-remotes/client": ["./packages/api/remotes/src/client/index.ts"], "@deepseek-ai/dsh-client-hmr": ["./packages/client/hmr/src"], "@deepseek-ai/dsh-client-modules": ["./packages/client/modules/src"], "@deepseek-ai/dsh-client-runtime": ["./packages/client/runtime/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 327b337963..9821c0e41b 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -52,8 +52,8 @@ { "path": "./packages/client/hmr" }, { "path": "./packages/client/connection" }, { "path": "./packages/typert/registry" }, - { "path": "./packages/host/api-gateway" }, - { "path": "./packages/client/remotes" }, + { "path": "./packages/api/gateway" }, + { "path": "./packages/api/remotes" }, { "path": "./packages/client/runtime" }, { "path": "./packages/client/test-runtime" }, { "path": "./packages/client/ui-layout" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 37c20c0d5c..6884839536 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -102,7 +102,7 @@ { "path": "./packages/core/scope" }, { "path": "./packages/typert/type-meta" }, { "path": "./packages/typert/registry" }, - { "path": "./packages/host/api-gateway" }, + { "path": "./packages/api/gateway" }, { "path": "./packages/typert/loader" }, { "path": "./packages/session-persistence/session-persistence" }, { "path": "./packages/session-persistence/session-checkpoint-policy" }, diff --git a/vitest.config.ts b/vitest.config.ts index 56a5a1575b..f5a86ac7a9 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -181,8 +181,8 @@ export default defineConfig({ 'packages/client/connection/src/http-bridge.ts', // This assembly imports generated Host-for-Client code that exists // only in lib; the post-build built-bin smoke executes both entries. - 'packages/client/remotes/src/index.ts', - 'packages/client/remotes/src/client/index.ts', + 'packages/api/remotes/src/index.ts', + 'packages/api/remotes/src/client/index.ts', // Slash/command/input round: per-file gaps deferred with the same // client-lane debt. TODO(gui): cover and remove with the lane above. 'packages/client/connection/src/client/fixture.ts', From 502bd2b6f736d8baafb115d512589003eba4c41c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:00:35 +0800 Subject: [PATCH 085/104] fix: docs --- docs/module-graph.md | 111 ++++++++----------------------------------- 1 file changed, 19 insertions(+), 92 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index 43923e0865..9cf6f4c895 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -421,13 +421,6 @@ flowchart TD pkg_app_boot --> pkg_invariants pkg_app_boot --> pkg_paths pkg_app_boot --> pkg_system_prompt -<<<<<<< HEAD - pkg_client_ui_layout --> pkg_client_runtime - pkg_client_ui_layout --> pkg_client_ui_slots - pkg_client_ui_layout --> pkg_client_ui_theme - pkg_client_ui_layout --> pkg_invariants -======= ->>>>>>> d6e02ebbcc (feat(goal): add TypeRT gateway example) pkg_code_runtime_worker --> pkg_code_runtime pkg_code_runtime_worker --> pkg_invariants pkg_code_runtime_worker --> pkg_session @@ -616,31 +609,15 @@ flowchart TD pkg_permission --> pkg_session_projection pkg_permission --> pkg_settings pkg_permission --> pkg_user_approval -<<<<<<< HEAD -<<<<<<< HEAD - pkg_client_ui_conversation --> pkg_client_locale - pkg_client_ui_conversation --> pkg_client_runtime - pkg_client_ui_conversation --> pkg_client_ui_primitives - pkg_client_ui_conversation --> pkg_client_ui_slash - pkg_client_ui_conversation --> pkg_client_ui_slots - pkg_client_ui_conversation --> pkg_invariants - pkg_client_ui_conversation --> pkg_token_meter - pkg_command_feedback --> pkg_commands - pkg_command_feedback --> pkg_invariants - pkg_command_feedback --> pkg_session -======= - pkg_client_remotes --> pkg_goal - pkg_client_remotes --> pkg_host_api_gateway - pkg_client_remotes --> pkg_invariants ->>>>>>> d6e02ebbcc (feat(goal): add TypeRT gateway example) -======= pkg_api_remotes --> pkg_agent pkg_api_remotes --> pkg_goal pkg_api_remotes --> pkg_invariants pkg_api_remotes --> pkg_session pkg_api_remotes --> pkg_session_persistence pkg_api_remotes --> pkg_typert_registry ->>>>>>> c671d9e48c (refactor(api): colocate gateway and remote assembly) + pkg_command_feedback --> pkg_commands + pkg_command_feedback --> pkg_invariants + pkg_command_feedback --> pkg_session pkg_pty_local --> pkg_agent pkg_pty_local --> pkg_invariants pkg_pty_local --> pkg_pty @@ -794,46 +771,10 @@ flowchart TD pkg_tool_ask_user --> pkg_invariants pkg_tool_ask_user --> pkg_tools pkg_tool_ask_user --> pkg_user_interaction -<<<<<<< HEAD -<<<<<<< HEAD - pkg_client_ui_command --> pkg_client_connection - pkg_client_ui_command --> pkg_client_locale - pkg_client_ui_command --> pkg_client_runtime - pkg_client_ui_command --> pkg_client_ui_conversation - pkg_client_ui_command --> pkg_client_ui_primitives - pkg_client_ui_command --> pkg_client_ui_slash - pkg_client_ui_command --> pkg_client_ui_slots - pkg_client_ui_command --> pkg_invariants - pkg_client_ui_deliverables --> pkg_client_locale - pkg_client_ui_deliverables --> pkg_client_runtime - pkg_client_ui_deliverables --> pkg_client_ui_conversation - pkg_client_ui_deliverables --> pkg_client_ui_slots - pkg_client_ui_deliverables --> pkg_invariants - pkg_client_ui_goal --> pkg_client_connection - pkg_client_ui_goal --> pkg_client_locale - pkg_client_ui_goal --> pkg_client_runtime - pkg_client_ui_goal --> pkg_client_ui_conversation - pkg_client_ui_goal --> pkg_client_ui_primitives - pkg_client_ui_goal --> pkg_client_ui_slots - pkg_client_ui_goal --> pkg_goal - pkg_client_ui_goal --> pkg_invariants - pkg_client_ui_skill --> pkg_client_connection - pkg_client_ui_skill --> pkg_client_locale - pkg_client_ui_skill --> pkg_client_runtime - pkg_client_ui_skill --> pkg_client_ui_conversation - pkg_client_ui_skill --> pkg_client_ui_primitives - pkg_client_ui_skill --> pkg_client_ui_slash - pkg_client_ui_skill --> pkg_client_ui_slots - pkg_client_ui_skill --> pkg_invariants -======= - pkg_client_runtime --> pkg_client_remotes -======= pkg_client_runtime --> pkg_api_remotes ->>>>>>> c671d9e48c (refactor(api): colocate gateway and remote assembly) pkg_client_runtime --> pkg_invariants pkg_client_runtime --> pkg_type_meta pkg_client_runtime --> pkg_typert_registry ->>>>>>> d6e02ebbcc (feat(goal): add TypeRT gateway example) pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compact pkg_session_reference --> pkg_invariants @@ -1104,11 +1045,6 @@ flowchart TD pkg_client_ui_layout --> pkg_client_ui_slots pkg_client_ui_layout --> pkg_client_ui_theme pkg_client_ui_layout --> pkg_invariants - pkg_client_ui_skill --> pkg_client_connection - pkg_client_ui_skill --> pkg_client_runtime - pkg_client_ui_skill --> pkg_client_ui_slash - pkg_client_ui_skill --> pkg_client_ui_slots - pkg_client_ui_skill --> pkg_invariants pkg_acp_demo --> pkg_acp pkg_acp_demo --> pkg_agent_spine_demo pkg_acp_demo --> pkg_app_boot @@ -1147,6 +1083,11 @@ flowchart TD pkg_client_ui_command --> pkg_client_ui_slash pkg_client_ui_command --> pkg_client_ui_slots pkg_client_ui_command --> pkg_invariants + pkg_client_ui_deliverables --> pkg_client_locale + pkg_client_ui_deliverables --> pkg_client_runtime + pkg_client_ui_deliverables --> pkg_client_ui_conversation + pkg_client_ui_deliverables --> pkg_client_ui_slots + pkg_client_ui_deliverables --> pkg_invariants pkg_client_ui_goal --> pkg_api_remotes pkg_client_ui_goal --> pkg_client_locale pkg_client_ui_goal --> pkg_client_runtime @@ -1163,6 +1104,14 @@ flowchart TD pkg_client_ui_plan --> pkg_client_ui_slots pkg_client_ui_plan --> pkg_invariants pkg_client_ui_plan --> pkg_plan_mode + pkg_client_ui_skill --> pkg_client_connection + pkg_client_ui_skill --> pkg_client_locale + pkg_client_ui_skill --> pkg_client_runtime + pkg_client_ui_skill --> pkg_client_ui_conversation + pkg_client_ui_skill --> pkg_client_ui_primitives + pkg_client_ui_skill --> pkg_client_ui_slash + pkg_client_ui_skill --> pkg_client_ui_slots + pkg_client_ui_skill --> pkg_invariants pkg_client_ui_subagent --> pkg_client_locale pkg_client_ui_subagent --> pkg_client_runtime pkg_client_ui_subagent --> pkg_client_ui_conversation @@ -1262,10 +1211,6 @@ flowchart TD | [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`app-boot`](../packages/ui/app-boot) | `ui` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) | -<<<<<<< HEAD -| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | -======= ->>>>>>> d6e02ebbcc (feat(goal): add TypeRT gateway example) | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | @@ -1309,16 +1254,8 @@ flowchart TD | [`session-title-llm`](../packages/session-title/session-title-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`timeout`](../packages/util/timeout) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/ui/user-approval) | -<<<<<<< HEAD -<<<<<<< HEAD -| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) | -| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -======= -| [`client-remotes`](../packages/client/remotes) | `client` | [`goal`](../packages/goal/goal), [`host-api-gateway`](../packages/host/api-gateway), [`invariants`](../packages/support/invariants) | ->>>>>>> d6e02ebbcc (feat(goal): add TypeRT gateway example) -======= | [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`typert-registry`](../packages/typert/registry) | ->>>>>>> c671d9e48c (refactor(api): colocate gateway and remote assembly) +| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -1344,18 +1281,7 @@ flowchart TD | [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`session-title-llm`](../packages/session-title/session-title-llm) | | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | -<<<<<<< HEAD -<<<<<<< HEAD -| [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | -| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -======= -| [`client-runtime`](../packages/client/runtime) | `client` | [`client-remotes`](../packages/client/remotes), [`invariants`](../packages/support/invariants), [`type-meta`](../packages/typert/type-meta), [`typert-registry`](../packages/typert/registry) | ->>>>>>> d6e02ebbcc (feat(goal): add TypeRT gateway example) -======= | [`client-runtime`](../packages/client/runtime) | `client` | [`api-remotes`](../packages/api/remotes), [`invariants`](../packages/support/invariants), [`type-meta`](../packages/typert/type-meta), [`typert-registry`](../packages/typert/registry) | ->>>>>>> c671d9e48c (refactor(api): colocate gateway and remote assembly) | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | @@ -1400,14 +1326,15 @@ flowchart TD | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/sdk-client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | -| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | +| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`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/support/invariants) | | [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | From ef8660076b05ed909065387e139592ffbf79329a Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:04:25 +0800 Subject: [PATCH 086/104] fix: docs budget --- scripts/doc-budgets.manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index a6ad066add..b5c000a714 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,5 +1,5 @@ { - "AGENTS.md": 1775, + "AGENTS.md": 1782, "docs/AGENTS.md": 1320, "docs/architecture.md": 2160, "docs/cordis-primer.md": 600, @@ -7,5 +7,5 @@ "docs/testing.md": 1150, "examples/AGENTS.md": 310, "packages/AGENTS.md": 675, - "packages/README.md": 920 + "packages/README.md": 936 } From d2596a0d74ed1729f687d2f2a224405be7f813a1 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:07:17 +0800 Subject: [PATCH 087/104] test(api-remotes): cover lookup publication races --- .../api/remotes/tests/agent-lookup.spec.ts | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 packages/api/remotes/tests/agent-lookup.spec.ts diff --git a/packages/api/remotes/tests/agent-lookup.spec.ts b/packages/api/remotes/tests/agent-lookup.spec.ts new file mode 100644 index 0000000000..c9110b8f3f --- /dev/null +++ b/packages/api/remotes/tests/agent-lookup.spec.ts @@ -0,0 +1,110 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SessionStore from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import { createApiRemoteAgentResolver } from '@deepseek-ai/dsh-api-remotes' + +const sid = (value: string): SessionId => value as SessionId + +function header(id: SessionId): SessionHeader { + return { version: 0, id, createdAt: 1, cwd: '/proj' } +} + +async function createContext(): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + return ctx +} + +function provideSession( + ctx: Context, + meta: SessionHeader, + inspect: () => Promise<{ meta: SessionHeader; events: SessionEvent[] }>, +): void { + ctx.provide('sessionPersistence', { + list: () => Promise.resolve([meta]), + inspect, + locate: () => undefined, + } as never) +} + +function stubAgent(ctx: Context, session: Session): Agent { + return { id: session.id, session, status: 'idle', ctx } as Agent +} + +describe('API Remote Agent resolver races', () => { + it('maps an inspected session without a cwd to session-not-found', async () => { + const ctx = await createContext() + const sessionId = sid('missing-after-inspect') + const meta = header(sessionId) + provideSession(ctx, meta, () => Promise.resolve({ + meta: { ...meta, cwd: undefined } as unknown as SessionHeader, + events: [], + })) + + const result = await createApiRemoteAgentResolver(ctx, {})(sessionId) + + expect(result).toMatchObject({ error: { code: 'session-not-found', details: { sessionId } } }) + await ctx.fiber.dispose() + }) + + it('resumes through a concurrently attached ordinary Session without optional defaults', async () => { + const ctx = await createContext() + const sessionId = sid('ordinary-attach-race') + const meta = header(sessionId) + let published: Session | undefined + provideSession(ctx, meta, () => { + published = ctx.sessions.create(sessionId, { meta: { cwd: '/proj' } }) + return Promise.resolve({ meta, events: [] }) + }) + const resume = vi.spyOn(ctx.agents, 'resume').mockImplementation(async () => { + if (published === undefined) throw new Error('Session was not published') + return { agent: stubAgent(ctx, published), dispose: () => Promise.resolve() } + }) + + const result = await createApiRemoteAgentResolver(ctx, {})(sessionId) + + expect(result).toMatchObject({ agent: { id: sessionId } }) + expect(resume).toHaveBeenCalledWith({ resumeSessionId: sessionId }) + await ctx.fiber.dispose() + }) + + it('rejects a subagent Session published after durable inspection', async () => { + const ctx = await createContext() + const sessionId = sid('owned-attach-race') + const meta = header(sessionId) + provideSession(ctx, meta, () => { + ctx.sessions.create(sessionId, { meta: { cwd: '/proj', origin: 'subagent' } }) + return Promise.resolve({ meta, events: [] }) + }) + const resume = vi.spyOn(ctx.agents, 'resume') + + const result = await createApiRemoteAgentResolver(ctx, {})(sessionId) + + expect(result).toMatchObject({ error: { code: 'agent-busy' } }) + expect(resume).not.toHaveBeenCalled() + await ctx.fiber.dispose() + }) + + it('reclassifies failed resumes after a live or attached subagent wins publication', async () => { + for (const winner of ['agent', 'session'] as const) { + const ctx = await createContext() + const sessionId = sid(`owned-${winner}-resume-race`) + const meta = header(sessionId) + provideSession(ctx, meta, () => Promise.resolve({ meta, events: [] })) + vi.spyOn(ctx.agents, 'resume').mockImplementationOnce(async () => { + const session = ctx.sessions.create(sessionId, { meta: { cwd: '/proj', origin: 'subagent' } }) + if (winner === 'agent') ctx.agents.register(stubAgent(ctx, session)) + throw new Error('session id already published') + }) + + const result = await createApiRemoteAgentResolver(ctx, {})(sessionId) + + expect(result).toMatchObject({ error: { code: 'agent-busy' } }) + await ctx.fiber.dispose() + } + }) +}) From d49028ff5daf083dab533fde27b039842d6da879 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:14:27 +0800 Subject: [PATCH 088/104] fix: docs --- docs/event-producer-consumer.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 92bf908613..9d486c46f6 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -30,10 +30,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:73`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:62`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:73`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:83`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:95`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | -| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:104`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:96`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | +| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:105`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:170`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | | `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:157`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:188`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | From e89d078819de825aa0fa1f40983daed1b76275e4 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:25:00 +0800 Subject: [PATCH 089/104] fix: test snapshot --- .../translation-prompt-v4/request-response.expected.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index e796de8a8a..794cf18f49 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -16,11 +16,11 @@ }, { "role": "user", - "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThe setup tutorial takes a new contributor from prerequisites to a checked checkout. The contributor reference that follows covers repository layout, daily workflow, and CI shape. Design rationale and implementation details belong to the linked Agent Notes and scripts.\n\n## Setup tutorial\n\n### Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the Web, headless, and ACP automation demos and real-API e2e tests.\n\n### First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also configures worktree-local lefthook hooks through `scripts/install-lefthook.mjs`. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the safety and migration contract.\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nIf the wrapper rejects existing Git configuration or reports a stale lock, follow its diagnostic and the linked Agent Note rather than editing worktree metadata speculatively. After moving a checkout, rerun the wrapper to regenerate the owned path.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nSetup is complete when `pnpm run typecheck` exits successfully.\n\n## Contributor reference\n\n### TypeScript project layout\n\nThe repository typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through two no-emit aggregates.\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- 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. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md).\n\nBusiness services declare callable methods on the Host with `@Remote` or `@RemoteContext`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `client-remotes` composition loads those contributions and mounts concrete methods on `ctx.api` or the corresponding scoped Context. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order.\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n### Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n### Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` applies formatting-only ESLint fixes, validates the staged files with Oxlint and applies its native fixes, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n### CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n### Daily commands\n\nThe root [contributor instructions](../AGENTS.md#commands) summarize common commands, while [`package.json`](../package.json) and [scripts/run-gates.ts](../scripts/run-gates.ts) own the current script and gate inventories. Select the smallest checks that cover the changed surface. Documentation changes use `pnpm run doc-sync`; package-public behavior changes also update the owning README or JSDoc, and built-artifact checks require `pnpm run build` first.\n\n### Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n### Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n" + "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThe setup tutorial takes a new contributor from prerequisites to a checked checkout. The contributor reference that follows covers repository layout, daily workflow, and CI shape. Design rationale and implementation details belong to the linked Agent Notes and scripts.\n\n## Setup tutorial\n\n### Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the Web, headless, and ACP automation demos and real-API e2e tests.\n\n### First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also configures worktree-local lefthook hooks through `scripts/install-lefthook.mjs`. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the safety and migration contract.\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nIf the wrapper rejects existing Git configuration or reports a stale lock, follow its diagnostic and the linked Agent Note rather than editing worktree metadata speculatively. After moving a checkout, rerun the wrapper to regenerate the owned path.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nSetup is complete when `pnpm run typecheck` exits successfully.\n\n## Contributor reference\n\n### TypeScript project layout\n\nThe repository typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through two no-emit aggregates.\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- 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. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md).\n\nBusiness services declare callable methods on the Host with `@Remote` or `@RemoteContext`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions and mounts concrete methods on `ctx.api` or the corresponding scoped Context. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order.\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n### Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n### Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` applies formatting-only ESLint fixes, validates the staged files with Oxlint and applies its native fixes, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n### CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n### Daily commands\n\nThe root [contributor instructions](../AGENTS.md#commands) summarize common commands, while [`package.json`](../package.json) and [scripts/run-gates.ts](../scripts/run-gates.ts) own the current script and gate inventories. Select the smallest checks that cover the changed surface. Documentation changes use `pnpm run doc-sync`; package-public behavior changes also update the owning README or JSDoc, and built-artifact checks require `pnpm run build` first.\n\n### Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n### Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n" }, { "role": "assistant", - "content": "# 开发指南\n\n[English](development.md) | 中文\n\n搭建教程引导新贡献者从准备前置条件开始,直到检出通过检查。后面的贡献者参考介绍仓库布局、日常工作流和 CI 形态。设计依据与实现细节属于链接的 Agent Note 和脚本。\n\n## 搭建教程\n\n### 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 Web、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n### 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程还会通过 `scripts/install-lefthook.mjs` 配置 worktree 本地的 lefthook 钩子。其安全与迁移契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 负责。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n如果包装脚本拒绝现有 Git 配置或报告陈旧锁,请遵循其诊断和所链接的 Agent Note,不要凭猜测编辑 worktree 元数据。移动检出目录后,请重新运行包装脚本以重新生成自有路径。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n`pnpm run typecheck` 成功退出即表示搭建完成。\n\n## 贡献者参考\n\n### TypeScript 项目布局\n\n仓库类型检查会执行全仓 `tsc -b` 图:它会发射每个 package/vendor 的 `lib/types`,并通过两个 no-emit 聚合检查示例、测试和脚本。\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。\n\n业务 Service 在 Host 使用 `@Remote` 或 `@RemoteContext` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `client-remotes` 组合加载这些贡献并将具体方法挂载到 `ctx.api` 或对应的作用域 Context。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n### 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n### Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 应用仅用于格式化的 ESLint 修复,使用 Oxlint 验证暂存文件并应用其原生修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n### CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n### 日常命令\n\n根目录的[贡献者说明](../AGENTS.md#commands)概述常用命令,[`package.json`](../package.json) 与 [scripts/run-gates.ts](../scripts/run-gates.ts) 则负责当前脚本和门禁清单。请选择覆盖变更表面的最小检查集。文档变更使用 `pnpm run doc-sync`;package 公开行为变更还需更新所属 README 或 JSDoc,而基于构建产物的检查需要先运行 `pnpm run build`。\n\n### 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n### 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n" + "content": "# 开发指南\n\n[English](development.md) | 中文\n\n搭建教程引导新贡献者从准备前置条件开始,直到检出通过检查。后面的贡献者参考介绍仓库布局、日常工作流和 CI 形态。设计依据与实现细节属于链接的 Agent Note 和脚本。\n\n## 搭建教程\n\n### 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 Web、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n### 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程还会通过 `scripts/install-lefthook.mjs` 配置 worktree 本地的 lefthook 钩子。其安全与迁移契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 负责。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n如果包装脚本拒绝现有 Git 配置或报告陈旧锁,请遵循其诊断和所链接的 Agent Note,不要凭猜测编辑 worktree 元数据。移动检出目录后,请重新运行包装脚本以重新生成自有路径。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n`pnpm run typecheck` 成功退出即表示搭建完成。\n\n## 贡献者参考\n\n### TypeScript 项目布局\n\n仓库类型检查会执行全仓 `tsc -b` 图:它会发射每个 package/vendor 的 `lib/types`,并通过两个 no-emit 聚合检查示例、测试和脚本。\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。\n\n业务 Service 在 Host 使用 `@Remote` 或 `@RemoteContext` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并将具体方法挂载到 `ctx.api` 或对应的作用域 Context。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n### 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n### Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 应用仅用于格式化的 ESLint 修复,使用 Oxlint 验证暂存文件并应用其原生修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n### CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n### 日常命令\n\n根目录的[贡献者说明](../AGENTS.md#commands)概述常用命令,[`package.json`](../package.json) 与 [scripts/run-gates.ts](../scripts/run-gates.ts) 则负责当前脚本和门禁清单。请选择覆盖变更表面的最小检查集。文档变更使用 `pnpm run doc-sync`;package 公开行为变更还需更新所属 README 或 JSDoc,而基于构建产物的检查需要先运行 `pnpm run build`。\n\n### 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n### 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n" }, { "role": "user", From 686ee5b3f6824b4bcdda4e334d59bf55ea0aec3d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:53:04 +0800 Subject: [PATCH 090/104] fix(api-gateway): harden remote lifecycle and recovery --- ...08-02-typert-remote-method-calls.i18n.yaml | 4 +- .../2026-08-02-typert-remote-method-calls.md | 2 +- ...026-08-02-typert-remote-method-calls.zh.md | 2 +- packages/api/gateway/src/client/index.ts | 20 ++++---- packages/api/gateway/src/index.ts | 9 ++-- packages/api/gateway/tests/client.spec.ts | 14 ++++++ packages/api/gateway/tests/gateway.spec.ts | 16 +++++++ packages/api/remotes/src/agent-lookup.ts | 1 + .../api/remotes/tests/agent-lookup.spec.ts | 44 +++++++++++++++++ packages/client/ui-goal/src/client/index.ts | 14 +++--- .../ui-goal/tests/browser-plugin.spec.tsx | 15 +++++- packages/typert/registry/README.i18n.yaml | 4 +- packages/typert/registry/README.md | 1 + packages/typert/registry/README.zh.md | 1 + packages/typert/registry/src/service.ts | 48 ++++++++++++++++++- packages/typert/registry/tests/typert.spec.ts | 30 ++++++++++++ packages/typert/type-meta/README.i18n.yaml | 4 +- packages/typert/type-meta/README.md | 2 +- packages/typert/type-meta/README.zh.md | 2 +- packages/typert/type-meta/src/index.ts | 1 + packages/typert/type-meta/src/types.ts | 18 ++++++- 21 files changed, 218 insertions(+), 34 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index 9ba0cf8dc1..1e4aeaabd7 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: c4f3a5b94bf25b4581b9430cfcb4f02f707e0749 -2026-08-02-typert-remote-method-calls.zh.md: e11d8ebe42d44cc9805e942a31f13f7ae847815a +2026-08-02-typert-remote-method-calls.md: 3d5a79fd4a26f7d232dcc7635625899e2eb9df6b +2026-08-02-typert-remote-method-calls.zh.md: 3d6ec680ba97a532f18219670e8dba799a94ed7b diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index c4f3a5b94b..3d5a79fd4a 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -164,7 +164,7 @@ Every registration returns a disposer owned by the caller's Cordis fiber. Client The lookup registry retains the stable wire declaration after its live resolver unloads. SRC parsing continues to classify the parameter as a lookup, while invocation fails with `lookup-unavailable`; it never reclassifies the incoming ID as an ordinary JSON business object. Re-registering the same key with different parameter, wire, or canonical type symbols fails for the lifetime of that TypeRT Service. -Business-object packages own stable declarations and default resolvers through `register()`; Host composition supplies an effect-scoped asynchronous policy for the same key through `configure()`. Configuration may precede provider registration, but does not by itself make a lookup available without a live provider; unloading the configuration restores the provider's default resolver. API Remotes creates the shared `agentFor()` resolver for `agent` and `session`: live Agents are reused, ordinary cold sessions are resumed automatically, concurrent resumes are deduplicated by Session ID, and the subagent ownership fence returns the existing `agent-busy`. The standard Web API Proxy supplies its Agent defaults and scope setup and consumes that resolver for legacy methods. The `session` resolver returns the resolved Agent's Session, so the two parameter kinds do not create separate resume lifecycles. +Business-object and scoped-Context packages own stable declarations and default resolvers through `lookups.register()` and `contexts.registerHost()`; Host composition supplies effect-scoped asynchronous policies through `lookups.configure()` and `contexts.configureHost()`. Configuration may precede provider registration, but does not by itself make an identity available without a live provider; unloading the configuration restores the provider's default resolver. API Remotes creates the shared `agentFor()` resolver for `agent` and `session` lookups and the `agent` Host Context: live Agents are reused, ordinary cold sessions are resumed automatically, concurrent resumes are deduplicated by Session ID, and the subagent ownership fence returns the existing `agent-busy`. The standard Web API Proxy supplies its Agent defaults and scope setup and consumes that resolver for legacy methods. The `session` lookup returns the resolved Agent's Session, while the `agent` Host Context returns its Context, so all three projections share one resume lifecycle. The registry's Host root entry has the complete `TypeRTService` interface merge. The registry implementation shared by Host and Client lives in a separate module without environment declarations. The registry's `/client` entry imports only that shared implementation and does not pass through the Host root entry, so it cannot bring Host Cordis declarations into the Client Program. diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index e11d8ebe42..3d6ec680ba 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -164,7 +164,7 @@ ctx.typert.contexts Host Context resolver 与 Client Context binder lookup 注册表会在活 resolver 卸载后保留稳定的 wire 声明。SRC 解析仍会把该参数归类为 lookup,而调用会以 `lookup-unavailable` 失败;系统绝不会把传入的 ID 重新归类为普通 JSON 业务对象。在同一个 TypeRT Service 的生命周期内,以不同参数、wire 或规范类型 symbol 重新注册同一 key 会直接失败。 -业务对象包通过 `register()` 拥有稳定声明和默认 resolver;Host 组合通过 `configure()` 为同一个 key 提供 effect-scoped 异步策略。配置可以先于 provider 注册,但没有活 provider 时不会单独形成可用 lookup;配置卸载后恢复 provider 默认 resolver。API Remotes 为 `agent` 和 `session` 创建共享的 `agentFor()` resolver:live Agent 直接复用,普通冷会话自动恢复,并发恢复按 Session ID 去重,subagent ownership fence 则返回既有 `agent-busy`。标准 Web API Proxy 提供 Agent 默认值和 scope 设置,并让旧方法使用该 resolver。`session` resolver 返回解析所得 Agent 的 Session,因而两种参数不会产生两套恢复生命周期。 +业务对象包和 scoped Context 包通过 `lookups.register()` 与 `contexts.registerHost()` 拥有稳定声明和默认 resolver;Host 组合通过 `lookups.configure()` 与 `contexts.configureHost()` 提供 effect-scoped 异步策略。配置可以先于 provider 注册,但没有活 provider 时不会单独形成可用身份;配置卸载后恢复 provider 默认 resolver。API Remotes 为 `agent`、`session` lookup 和 `agent` Host Context 创建共享的 `agentFor()` resolver:live Agent 直接复用,普通冷会话自动恢复,并发恢复按 Session ID 去重,subagent ownership fence 则返回既有 `agent-busy`。标准 Web API Proxy 提供 Agent 默认值和 scope 设置,并让旧方法使用该 resolver。`session` lookup 返回解析所得 Agent 的 Session,`agent` Host Context 返回其 Context,因此三种投影共用一个恢复生命周期。 Registry 的 Host 根入口拥有完整 `TypeRTService` interface merge;Host 与 Client 共用的 registry 实现位于无环境声明的独立模块。Registry `/client` 入口只引用该共享实现,不经过 Host 根入口,因此不会把 Host Cordis 声明带入 Client Program。 diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index bafffa80f7..ddef36e288 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -134,7 +134,8 @@ class ClientApiService extends Service implements TypeRTClientApi { for (const method of methods) record.service.assertMethodAvailable(method) } else { for (const method of methods) ScopedRemoteNamespace.assertMethodAvailable(namespace, method) - if (this.ownerCtx.reflect.props[namespace] !== undefined) { + const property = this.ownerCtx.reflect.props[namespace] + if (property?.type === 'accessor' || this.ownerCtx.get(namespace) !== undefined) { throw new Error(`client api: scoped namespace ${JSON.stringify(namespace)} conflicts with an existing Context property`) } } @@ -224,6 +225,7 @@ class ClientApiService extends Service implements TypeRTClientApi { if (namespace.tokens.get(descriptor.method) !== token) return namespace.service.remove(descriptor.method) namespace.tokens.delete(descriptor.method) + if (namespace.tokens.size === 0) this.scoped.delete(descriptor.namespace) } } @@ -289,7 +291,7 @@ class ScopedRemoteNamespace { private readonly ctx: Context private readonly ownerCtx: Context private readonly methods = new Set() - private provided = false + private disposeService: (() => void) | undefined readonly name: string static assertMethodAvailable(namespace: string, method: string): void { @@ -331,12 +333,7 @@ class ScopedRemoteNamespace { }, }) if (activate) { - if (this.provided) { - this.ownerCtx.set(this.name, this) - } else { - this.ownerCtx.reflect.provide(this.name, this) - this.provided = true - } + this.disposeService = this.ownerCtx.reflect.provide(this.name, this) } } catch (error) { Reflect.deleteProperty(this, method) @@ -348,11 +345,14 @@ class ScopedRemoteNamespace { remove(method: string): void { Reflect.deleteProperty(this, method) this.methods.delete(method) - if (this.methods.size === 0) this.ownerCtx.set(this.name, undefined) + if (this.methods.size !== 0) return + const disposeService = this.disposeService + this.disposeService = undefined + disposeService?.() } } -const SCOPED_NAMESPACE_FIELDS = new Set(['ctx', 'invokeRemote', 'methods', 'name', 'ownerCtx', 'provided']) +const SCOPED_NAMESPACE_FIELDS = new Set(['ctx', 'disposeService', 'invokeRemote', 'methods', 'name', 'ownerCtx']) function endpointOf(descriptor: Pick): string { return `${descriptor.namespace}/${descriptor.method}` diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index 13cf460f4d..5899f5d560 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -134,7 +134,7 @@ export class TypertGatewayService extends Service implements TypertGateway { const endpoint = endpointOf(request.namespace, request.method) const descriptor = this.resolveDescriptor(request.namespace, request.method, endpoint) assertExactArguments(request.args, descriptor, endpoint) - const receiverContext = this.resolveReceiverContext(descriptor, request.args, endpoint) + const receiverContext = await this.resolveReceiverContext(descriptor, request.args, endpoint) const receiver = receiverContext.get(descriptor.service) as unknown if (!isObject(receiver)) { throw new TypertGatewayError( @@ -331,11 +331,11 @@ export class TypertGatewayService extends Service implements TypertGateway { } } - private resolveReceiverContext( + private async resolveReceiverContext( descriptor: InvocationDescriptor, args: Readonly>, endpoint: string, - ): Context { + ): Promise { if (descriptor.invocation.kind === 'direct') return this.ctx const invocation = descriptor.invocation const provider = this.ctx.typert.contexts.getHost(invocation.context) @@ -358,8 +358,9 @@ export class TypertGatewayService extends Service implements TypertGateway { const identity = decode(invocation.codec, args[invocation.wire], 'input-invalid', endpoint, invocation.wire) let context: Context | undefined try { - context = provider.resolve(identity) + context = await provider.resolve(identity) } catch (cause) { + if (cause instanceof TypeRTLookupFailure) throw cause throw new TypertGatewayError( 'context-failed', endpoint, diff --git a/packages/api/gateway/tests/client.spec.ts b/packages/api/gateway/tests/client.spec.ts index 2fbcbb9280..feae3056c9 100644 --- a/packages/api/gateway/tests/client.spec.ts +++ b/packages/api/gateway/tests/client.spec.ts @@ -526,6 +526,20 @@ describe('Client TypeRT API', () => { await retry() }) + it('unregisters an empty scoped namespace so another provider can claim its name', async () => { + const ctx = await bench(vi.fn()) + const dispose = ctx.api.mount({ package: '@fixture/scoped', descriptors: [contextDescriptor()] }) + expect(ctx.get('goals')).toBeDefined() + + await dispose() + + expect(ctx.get('goals')).toBeUndefined() + const replacement = { owner: 'replacement' } + const disposeReplacement = ctx.reflect.provide('goals', replacement) + expect(ctx.get('goals')).toBe(replacement) + await disposeReplacement() + }) + it('throws RPC failures with the structured error as its cause', async () => { const rpcError = { code: 'internal' as const, message: 'host failed', details: {} } const ctx = await bench(vi.fn().mockResolvedValue({ ok: false, error: rpcError })) diff --git a/packages/api/gateway/tests/gateway.spec.ts b/packages/api/gateway/tests/gateway.spec.ts index d784a1ac2f..d298116b82 100644 --- a/packages/api/gateway/tests/gateway.spec.ts +++ b/packages/api/gateway/tests/gateway.spec.ts @@ -538,6 +538,22 @@ describe('TypertGatewayService', () => { expect(error.cause).toEqual(new Error('provider failed')) }) + it('preserves a Host Context policy rejection for the active RPC adapter', async () => { + const { ctx } = await setup() + const rejection = new TypeRTLookupFailure({ code: 'agent-busy', message: 'owned', details: { reason: 'subagent' } }) + ctx.typert.contexts.registerHost('gatewayFixture', { + ...contextProvider(ctx.extend()), + resolve: async () => { throw rejection }, + }) + registerStrict(ctx, [renameDescriptor()]) + + await expect(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'rename', + args: { agentId: 'agent-1', request: { title: 'land' } }, + })).rejects.toBe(rejection) + }) + it('reports Context provider metadata mismatch and unresolved identities', async () => { const { ctx } = await setup() registerStrict(ctx, [renameDescriptor()]) diff --git a/packages/api/remotes/src/agent-lookup.ts b/packages/api/remotes/src/agent-lookup.ts index e3a5b27df8..eb54ea9b0b 100644 --- a/packages/api/remotes/src/agent-lookup.ts +++ b/packages/api/remotes/src/agent-lookup.ts @@ -187,6 +187,7 @@ export function createApiRemoteAgentResolver( } typeCtx.typert.lookups.configure('agent', resolveAgent) typeCtx.typert.lookups.configure('session', async sessionId => (await resolveAgent(sessionId)).session) + typeCtx.typert.contexts.configureHost('agent', async sessionId => (await resolveAgent(sessionId)).ctx) }) return agentFor diff --git a/packages/api/remotes/tests/agent-lookup.spec.ts b/packages/api/remotes/tests/agent-lookup.spec.ts index c9110b8f3f..7179f5b2b2 100644 --- a/packages/api/remotes/tests/agent-lookup.spec.ts +++ b/packages/api/remotes/tests/agent-lookup.spec.ts @@ -5,6 +5,8 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import SessionStore from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import { createApiRemoteAgentResolver } from '@deepseek-ai/dsh-api-remotes' +import { TypeRTLookupFailure } from '@deepseek-ai/dsh-type-meta' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' const sid = (value: string): SessionId => value as SessionId @@ -14,6 +16,7 @@ function header(id: SessionId): SessionHeader { async function createContext(): Promise { const ctx = new Context() + await ctx.plugin(TypertRegistry) await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) return ctx @@ -107,4 +110,45 @@ describe('API Remote Agent resolver races', () => { await ctx.fiber.dispose() } }) + + it('uses the shared cold-resume policy for the Agent Host Context', async () => { + const ctx = await createContext() + const sessionId = sid('context-cold-resume') + const meta = header(sessionId) + let published: Session | undefined + provideSession(ctx, meta, () => { + published = ctx.sessions.create(sessionId, { meta: { cwd: '/proj' } }) + return Promise.resolve({ meta, events: [] }) + }) + const agentCtx = ctx.extend() + vi.spyOn(ctx.agents, 'resume').mockImplementation(async () => { + if (published === undefined) throw new Error('Session was not published') + return { agent: stubAgent(agentCtx, published), dispose: () => Promise.resolve() } + }) + const defaultProvider = ctx.typert.contexts.getHost('agent') + createApiRemoteAgentResolver(ctx, {}) + await vi.waitFor(() => { expect(ctx.typert.contexts.getHost('agent')).not.toBe(defaultProvider) }) + const provider = ctx.typert.contexts.getHost('agent') + if (provider === undefined) throw new Error('Agent Host Context provider was not mounted') + + await expect(provider.resolve(sessionId)).resolves.toBe(agentCtx) + await ctx.fiber.dispose() + }) + + it('applies the subagent ownership fence to the Agent Host Context', async () => { + const ctx = await createContext() + const sessionId = sid('context-owned-subagent') + const session = ctx.sessions.create(sessionId, { meta: { cwd: '/proj', origin: 'subagent' } }) + ctx.agents.register(stubAgent(ctx.extend(), session)) + const defaultProvider = ctx.typert.contexts.getHost('agent') + createApiRemoteAgentResolver(ctx, {}) + await vi.waitFor(() => { expect(ctx.typert.contexts.getHost('agent')).not.toBe(defaultProvider) }) + const provider = ctx.typert.contexts.getHost('agent') + if (provider === undefined) throw new Error('Agent Host Context provider was not mounted') + + const resolution = provider.resolve(sessionId) + await expect(resolution).rejects.toBeInstanceOf(TypeRTLookupFailure) + await expect(resolution).rejects.toMatchObject({ failure: { code: 'agent-busy' } }) + await ctx.fiber.dispose() + }) }) diff --git a/packages/client/ui-goal/src/client/index.ts b/packages/client/ui-goal/src/client/index.ts index 2e49d5b6b8..bea4f67df2 100644 --- a/packages/client/ui-goal/src/client/index.ts +++ b/packages/client/ui-goal/src/client/index.ts @@ -38,10 +38,10 @@ const NS = 'goal' /** Required services: slots for the dock entry, sessions for the projected ref, API for Remote mutations, locale for the copy. */ export const inject = ['slots', 'sessions', 'api', 'locale'] -/** Map one generated Remote call onto the strip's inline-render shape. */ -async function settle(result: Promise): Promise { +/** Map one generated Remote call, including synchronous namespace lookup failures, onto the strip's inline-render shape. */ +async function settle(invoke: () => Promise): Promise { try { - await result + await invoke() return { ok: true } } catch (error) { const cause = error instanceof Error ? error.cause : undefined @@ -94,22 +94,22 @@ export function apply(ctx: ClientContext): void { onEdit: async (objective) => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(ctx.api.goals.edit(sessionId, ref, { objective })) + return settle(() => ctx.api.goals.edit(sessionId, ref, { objective })) }, onPause: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(ctx.api.goals.pause(sessionId, ref)) + return settle(() => ctx.api.goals.pause(sessionId, ref)) }, onResume: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(ctx.api.goals.resume(sessionId, ref)) + return settle(() => ctx.api.goals.resume(sessionId, ref)) }, onClear: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(ctx.api.goals.clear(sessionId, ref)) + return settle(() => ctx.api.goals.clear(sessionId, ref)) }, }), }, GoalDock)) diff --git a/packages/client/ui-goal/tests/browser-plugin.spec.tsx b/packages/client/ui-goal/tests/browser-plugin.spec.tsx index 11c95e27d9..f900682712 100644 --- a/packages/client/ui-goal/tests/browser-plugin.spec.tsx +++ b/packages/client/ui-goal/tests/browser-plugin.spec.tsx @@ -70,7 +70,7 @@ async function bench(options: { resume: answer(`${prefix}/resume`, { ref }), clear: answer(`${prefix}/clear`, ref), }) - let activeGoals = goals('goals') + let activeGoals: ReturnType | undefined = goals('goals') ctx.provide('api', { get goals() { return activeGoals }, }) @@ -95,6 +95,7 @@ async function bench(options: { fiber, calls, remountGoals: () => { activeGoals = goals('remounted-goals') }, + unmountGoals: () => { activeGoals = undefined }, entry: () => { const entry = ctx.slots.entries('conversation.input.dock')[0] if (entry === undefined) return undefined @@ -141,6 +142,18 @@ describe('ui-goal browser plugin', () => { expect(b.calls).toMatchObject([{ method: 'remounted-goals/pause' }]) }) + it('settles every verb when the Remote namespace is temporarily absent', async () => { + const b = await bench({ projection: makeProjection() }) + await b.fiber.await() + const verbs = b.entry()!.inject!(sid('s1')) + b.unmountGoals() + + for (const result of [await verbs.onEdit('x'), await verbs.onPause(), await verbs.onResume(), await verbs.onClear()]) { + expect(result).toMatchObject({ ok: false, error: { code: 'internal' } }) + } + expect(b.calls).toHaveLength(0) + }) + it('a null or absent projection short-circuits every verb without touching the wire', async () => { for (const projection of [null, undefined]) { const b = await bench({ projection }) diff --git a/packages/typert/registry/README.i18n.yaml b/packages/typert/registry/README.i18n.yaml index a6180c6bfc..011834c52d 100644 --- a/packages/typert/registry/README.i18n.yaml +++ b/packages/typert/registry/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/typert/registry/README.md -README.md: dae8c3ed124fd6e2d61eb47964e2c07dda762b48 -README.zh.md: aea74b3753feccd88ee132363dc60ade02161498 +README.md: fa227b1c8faf1abd5a6492d4b8fe7d0c51ceeef1 +README.zh.md: 343e43aaca6ddaa0bb5e8d3130c85f37e4b4cb93 diff --git a/packages/typert/registry/README.md b/packages/typert/registry/README.md index dae8c3ed12..fa227b1c8f 100644 --- a/packages/typert/registry/README.md +++ b/packages/typert/registry/README.md @@ -10,6 +10,7 @@ Package reflection is keyed by `#`. Schemas are keyed by `>() + private readonly hostResolvers = new Map>() private readonly clients = new Map>() private readonly changes: ChangeSource @@ -347,16 +349,56 @@ class ContextStore { key: K, provider: TypeRTHostContextProvider>, ) => this.registerHost(ctx, key, provider), + configureHost: >( + key: K, + resolver: TypeRTHostContextResolver>, + ) => this.configureHost(ctx, key, resolver), registerClient: >( key: K, binder: TypeRTClientContextBinder>, ) => this.registerClient(ctx, key, binder), - getHost: key => this.hosts.get(key)?.provider, + getHost: key => this.getHost(key), getClient: key => this.clients.get(key)?.provider, subscribe: listener => this.changes.subscribe(ctx, listener), } } + private getHost(key: string): TypeRTHostContextProvider | undefined { + const provider = this.hosts.get(key)?.provider + if (provider === undefined) return undefined + const resolver = this.hostResolvers.get(key)?.provider + if (resolver === undefined) return provider + return { + wire: provider.wire, + wireTypeSymbol: provider.wireTypeSymbol, + resolve: id => resolver.resolve(id), + } + } + + private configureHost( + ctx: Context, + key: string, + resolver: TypeRTHostContextResolver, + ): TypeRTDisposer { + validateSegment('Context key', key) + if (this.hostResolvers.has(key)) throw new Error(`typert: host-context "${key}" resolver is already configured`) + const entry: ProviderEntry = { + provider: { resolve: async id => resolver(id as Wire) }, + owner: {}, + } + const { hostResolvers, changes } = this + return ctx.effect(function* () { + hostResolvers.set(key, entry) + changes.emit({ kind: 'host-context', key }) + yield () => { + /* v8 ignore next -- duplicate configuration is rejected, so this effect remains the key's unique owner. */ + if (hostResolvers.get(key) !== entry) return + hostResolvers.delete(key) + changes.emit({ kind: 'host-context', key }) + } + }, `typert.contexts.configureHost(${JSON.stringify(key)})`) + } + private registerHost(ctx: Context, key: string, provider: TypeRTHostContextProvider): TypeRTDisposer { validateSegment('Context key', key) validateWireName('Context wire field', provider.wire) @@ -392,6 +434,10 @@ class ContextStore { } } +interface HostContextResolverEntry { + resolve(id: unknown): Promise +} + /** * Registry of generated schemas, package reflection, invocations, and Remote * dependency providers. diff --git a/packages/typert/registry/tests/typert.spec.ts b/packages/typert/registry/tests/typert.spec.ts index 087cf00fc4..92b81803c7 100644 --- a/packages/typert/registry/tests/typert.spec.ts +++ b/packages/typert/registry/tests/typert.spec.ts @@ -389,6 +389,36 @@ describe('TypertRegistry', () => { await disposeReloadedProvider() }) + it('configures an asynchronous Host Context resolver independently of provider load order', async () => { + const ctx = await makeCtx() + const fallback = ctx.extend() + const configured = ctx.extend() + const disposeResolver = ctx.typert.contexts.configureHost('registryFixture', async id => + id === 'configured' ? configured : undefined) + + expect(ctx.typert.contexts.getHost('registryFixture')).toBeUndefined() + const disposeProvider = ctx.typert.contexts.registerHost('registryFixture', { + wire: 'agentId', + wireTypeSymbol: '@fixture/session#SessionId', + resolve: id => id === 'fallback' ? fallback : undefined, + }) + await expect(ctx.typert.contexts.getHost('registryFixture')?.resolve('configured')).resolves.toBe(configured) + expect(() => ctx.typert.contexts.configureHost('registryFixture', () => undefined)).toThrow('already configured') + + await disposeProvider() + expect(ctx.typert.contexts.getHost('registryFixture')).toBeUndefined() + const disposeReloadedProvider = ctx.typert.contexts.registerHost('registryFixture', { + wire: 'agentId', + wireTypeSymbol: '@fixture/session#SessionId', + resolve: id => id === 'fallback' ? fallback : undefined, + }) + await expect(ctx.typert.contexts.getHost('registryFixture')?.resolve('configured')).resolves.toBe(configured) + + await disposeResolver() + expect(ctx.typert.contexts.getHost('registryFixture')?.resolve('fallback')).toBe(fallback) + await disposeReloadedProvider() + }) + it('publishes provider changes, rejects duplicate providers, and disposes subscriptions', async () => { const ctx = await makeCtx() const changes: string[] = [] diff --git a/packages/typert/type-meta/README.i18n.yaml b/packages/typert/type-meta/README.i18n.yaml index 510b8d3854..6c21127e54 100644 --- a/packages/typert/type-meta/README.i18n.yaml +++ b/packages/typert/type-meta/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/typert/type-meta/README.md -README.md: b394c843409e840b75bbb08b128614379e528001 -README.zh.md: 5bd9bb18289a0320e0603d8b373e60d7f1e3c7e5 +README.md: a76169742cb78d0d19814bcd0f978c71036a5a1c +README.zh.md: 6f2d2fd6e241441fae8102c0639608e9b27b9bec diff --git a/packages/typert/type-meta/README.md b/packages/typert/type-meta/README.md index b394c84340..a76169742c 100644 --- a/packages/typert/type-meta/README.md +++ b/packages/typert/type-meta/README.md @@ -20,7 +20,7 @@ Decorator initializers retain markers in a module-private `WeakMap` keyed by the Business packages extend `TypeRTLookupMap` and `TypeRTContextMap` to associate Host objects or scoped Contexts with their wire identities. Generated artifacts extend `TypeRTRemoteMap`, `TypeRTRemoteContextMap`, and `TypeRTRemoteNamespaceMap` so Client imports expose only selected Remote methods. `InvocationDescriptor` is the shared runtime form consumed by the registry, Gateway, and Client API. -Lookup and Context packages own both sides of their contract: declaration merging supplies the static association, while runtime providers register identity resolution with `ctx.typert`. A lookup provider supplies the stable declaration and default resolver, while Host composition may separately configure a synchronous or asynchronous resolver; policy rejections may use `TypeRTLookupFailure` to carry a failure value owned by the boundary adapter. Strict codecs carry generated schemas; `src-json` codecs identify the weaker source-launch path. +Lookup and Context packages own both sides of their contract: declaration merging supplies the static association, while runtime providers register identity resolution with `ctx.typert`. A lookup or Host Context provider supplies the stable declaration and default resolver, while Host composition may separately configure a synchronous or asynchronous resolver; policy rejections may use `TypeRTLookupFailure` to carry a failure value owned by the boundary adapter. Strict codecs carry generated schemas; `src-json` codecs identify the weaker source-launch path. ## Model Experience diff --git a/packages/typert/type-meta/README.zh.md b/packages/typert/type-meta/README.zh.md index 5bd9bb1828..6f2d2fd6e2 100644 --- a/packages/typert/type-meta/README.zh.md +++ b/packages/typert/type-meta/README.zh.md @@ -20,7 +20,7 @@ Host 方法通过将 `signal: AbortSignal` 声明为最后一个参数来启用 业务包扩展 `TypeRTLookupMap` 和 `TypeRTContextMap`,以关联 Host 对象或作用域 Context 与其协议身份。生成的产物扩展 `TypeRTRemoteMap`、`TypeRTRemoteContextMap` 和 `TypeRTRemoteNamespaceMap`,使 Client 导入后仅暴露选定的 Remote 方法。`InvocationDescriptor` 是供注册表、Gateway 和 Client API 使用的共享运行时形式。 -查找包与 Context 包同时负责其契约的两侧:声明合并提供静态关联,运行时提供方则向 `ctx.typert` 注册身份解析。lookup provider 提供稳定声明与默认 resolver,Host 组合可以另行配置同步或异步 resolver;策略拒绝可用 `TypeRTLookupFailure` 携带由边界适配器拥有的失败值。严格编解码器携带生成的 schema;`src-json` 编解码器标识约束更弱的源码启动路径。 +查找包与 Context 包同时负责其契约的两侧:声明合并提供静态关联,运行时提供方则向 `ctx.typert` 注册身份解析。lookup 或 Host Context provider 提供稳定声明与默认 resolver,Host 组合可以另行配置同步或异步 resolver;策略拒绝可用 `TypeRTLookupFailure` 携带由边界适配器拥有的失败值。严格编解码器携带生成的 schema;`src-json` 编解码器标识约束更弱的源码启动路径。 ## 模型体验 diff --git a/packages/typert/type-meta/src/index.ts b/packages/typert/type-meta/src/index.ts index 1418c9d7f2..2f687f985f 100644 --- a/packages/typert/type-meta/src/index.ts +++ b/packages/typert/type-meta/src/index.ts @@ -50,6 +50,7 @@ export type { TypeRTContextWire, TypeRTDisposer, TypeRTHostContextProvider, + TypeRTHostContextResolver, TypeRTLocalRegistry, TypeRTLookup, TypeRTLookupDefinition, diff --git a/packages/typert/type-meta/src/types.ts b/packages/typert/type-meta/src/types.ts index b65690115f..ed309b7857 100644 --- a/packages/typert/type-meta/src/types.ts +++ b/packages/typert/type-meta/src/types.ts @@ -238,9 +238,14 @@ export interface TypeRTHostContextProvider { * @param id - validated wire identity. * @returns the scoped Context, or `undefined` when unavailable. */ - resolve(id: Wire): Context | undefined + resolve(id: Wire): Context | undefined | Promise } +/** Composition-owned resolver replacing one Host Context provider's default lookup policy. */ +export type TypeRTHostContextResolver = ( + id: Wire, +) => Context | undefined | Promise + /** Client resolver for the identity carried by the calling scoped Context. */ export interface TypeRTClientContextBinder { /** @@ -367,6 +372,17 @@ export interface TypeRTContextRegistry { key: K, provider: TypeRTHostContextProvider>, ): TypeRTDisposer + /** + * Override one Host Context key's identity policy for the calling fiber. + * Configuration may precede provider registration and restores the provider's default resolver on disposal. + * @param key - merge-declared Context key. + * @param resolver - composition-owned resolver used by every Host Context lookup of this key. + * @returns disposer restoring the provider's default resolver. + */ + configureHost>( + key: K, + resolver: TypeRTHostContextResolver>, + ): TypeRTDisposer /** * Register a Client Context identity binder. * @param key - merge-declared Context key. From d3b7ff17f005096031a5e25c255b0010d1fca13c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:58:43 +0800 Subject: [PATCH 091/104] fix: ci --- docs/cordis-catalog/services.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index cf763dca98..4a73dc06ad 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2593,7 +2593,7 @@ listPackages(filter: TypertPackageFilter = {}): TypertPackageRecord[] toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema.BaseSchema ``` -Source: [`packages/typert/registry/src/service.ts:400`](../../packages/typert/registry/src/service.ts) +Source: [`packages/typert/registry/src/service.ts:446`](../../packages/typert/registry/src/service.ts) ## `ctx.typertGateway` — `TypertGatewayService` From 146097368b4e2398db7b1a866144ab6d363f2803 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:21:37 +0800 Subject: [PATCH 092/104] fix(api-gateway): type async service disposer --- packages/api/gateway/src/client/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index ddef36e288..a9343823ff 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -11,6 +11,7 @@ import type { InvocationDescriptor, TypeRTClientApi, TypeRTCodec, + TypeRTDisposer, TypeRTRemoteContribution, } from '@deepseek-ai/dsh-type-meta' @@ -291,7 +292,7 @@ class ScopedRemoteNamespace { private readonly ctx: Context private readonly ownerCtx: Context private readonly methods = new Set() - private disposeService: (() => void) | undefined + private disposeService: TypeRTDisposer | undefined readonly name: string static assertMethodAvailable(namespace: string, method: string): void { @@ -348,7 +349,7 @@ class ScopedRemoteNamespace { if (this.methods.size !== 0) return const disposeService = this.disposeService this.disposeService = undefined - disposeService?.() + void disposeService?.() } } From d6ffd87c5f1b193d698620e716a261743b9324dc Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:28:30 +0800 Subject: [PATCH 093/104] refactor(api): expose traced remote namespaces --- ...08-02-typert-remote-method-calls.i18n.yaml | 4 +- .../2026-08-02-typert-remote-method-calls.md | 72 +-- ...026-08-02-typert-remote-method-calls.zh.md | 72 +-- docs/api-gateway.i18n.yaml | 4 +- docs/api-gateway.md | 22 +- docs/api-gateway.zh.md | 22 +- docs/core-data-structures/typert.i18n.yaml | 4 +- docs/core-data-structures/typert.md | 16 +- docs/core-data-structures/typert.zh.md | 16 +- docs/development.i18n.yaml | 4 +- docs/development.md | 2 +- docs/development.zh.md | 2 +- packages/api/README.i18n.yaml | 4 +- packages/api/README.md | 6 +- packages/api/README.zh.md | 6 +- packages/api/gateway/README.i18n.yaml | 4 +- packages/api/gateway/README.md | 8 +- packages/api/gateway/README.zh.md | 8 +- packages/api/gateway/src/client/index.ts | 414 +++++++++++------- packages/api/gateway/tests/client.spec.ts | 245 +++++------ packages/api/remotes/README.i18n.yaml | 4 +- packages/api/remotes/README.md | 6 +- packages/api/remotes/README.zh.md | 6 +- packages/api/remotes/src/client/index.ts | 16 +- packages/api/remotes/tests/built-lib.e2e.ts | 8 +- .../client/runtime/src/client/agents/scope.ts | 11 +- .../runtime/src/client/contract/sessions.ts | 5 +- packages/client/runtime/src/client/index.ts | 4 +- .../client/runtime/tests/client-apply.spec.ts | 3 +- .../client/runtime/tests/wire-events.spec.ts | 3 +- packages/client/ui-goal/README.i18n.yaml | 4 +- packages/client/ui-goal/README.md | 2 +- packages/client/ui-goal/README.zh.md | 2 +- packages/client/ui-goal/src/client/index.ts | 12 +- .../ui-goal/tests/browser-plugin.spec.tsx | 15 +- .../generator/tests/remote-model.spec.ts | 10 +- packages/typert/type-meta/src/index.ts | 2 +- packages/typert/type-meta/src/types.ts | 10 +- 38 files changed, 566 insertions(+), 492 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index 1e4aeaabd7..341bf44923 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: 3d5a79fd4a26f7d232dcc7635625899e2eb9df6b -2026-08-02-typert-remote-method-calls.zh.md: 3d6ec680ba97a532f18219670e8dba799a94ed7b +2026-08-02-typert-remote-method-calls.md: a8254090e042e4b359ae74fc5c19bad8abc5ef89 +2026-08-02-typert-remote-method-calls.zh.md: f1b7e5f9c61b474379962ce007e5d6bb966e5ebd diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index 3d5a79fd4a..a8254090e0 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -18,11 +18,11 @@ The Host and Browser Client use separate TypeScript Programs because each side a A business Service extends `GatewayService` and declares callable methods with `@Remote` or `@RemoteContext()`. A Service that already has another base class may instead expose the same binding through `bindTypeRTGateway()`. TypeRT generates the Host-local reflection artifact and a platform-independent Remote consumer projection from the Host Program. The Client Program continues to generate its own local reflection artifact independently. -The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. The `.d.ts` exposes only methods marked with a Remote decorator and refers to the business package's single public type symbols. The `.d.ts.map` navigates consumer API methods back to their Host business method implementations. The `.js` carries endpoint, parameter, Context, and Zod information for the same contract. At the assembly layer, the Browser Client mounts the required Remote JS contributions onto the Client API Service. The projection and API abstraction remain platform-independent so that a future TUI can reuse them. +The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. The `.d.ts` exposes only methods marked with a Remote decorator and refers to the business package's single public type symbols. The `.d.ts.map` navigates consumer API methods back to their Host business method implementations. The `.js` carries endpoint, parameter, Context, and Zod information for the same contract. At the assembly layer, the Browser Client mounts the required Remote JS contributions onto the Client Remote Service. The projection and Remote abstraction remain platform-independent so that a future TUI can reuse them. -`@deepseek-ai/dsh-api-gateway`, located at `packages/api/gateway`, provides two symmetric faces: its default entry provides Host `ctx.typertGateway`, while its `/client` entry provides consumer-side `ctx.api`. Each side consumes a locally generated `InvocationDescriptor` from the same model; descriptors are not sent over the wire. The Remote data protocol runs over Connection's shared `/api` RPC channel. The business calling interface does not change when Connection migrates from HTTP to WebSocket. +`@deepseek-ai/dsh-api-gateway`, located at `packages/api/gateway`, provides two symmetric faces: its default entry provides Host `ctx.typertGateway`, while its `/client` entry provides consumer-side `ctx.remote`. Each side consumes a locally generated `InvocationDescriptor` from the same model; descriptors are not sent over the wire. The Remote data protocol runs over Connection's shared `/api` RPC channel. The business calling interface does not change when Connection migrates from HTTP to WebSocket. -`@deepseek-ai/dsh-api-remotes`, located at `packages/api/remotes`, is the BFF layer above the Gateway. Its Host entry owns Agent/Session identity resolution and TypeRT lookup configuration; its `/client` entry selects the generated Remote contributions exposed by the application. The Client entry consumes the shared `TypeRTClientApi` contract through Cordis rather than importing the concrete Gateway implementation. +`@deepseek-ai/dsh-api-remotes`, located at `packages/api/remotes`, is the BFF layer above the Gateway. Its Host entry owns Agent/Session identity resolution and TypeRT lookup configuration; its `/client` entry selects the generated Remote contributions exposed by the application. The Client entry consumes the shared `TypeRTClientRemote` contract through Cordis rather than importing the concrete Gateway implementation. ## Components and Cordis services @@ -33,12 +33,12 @@ The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. T | TypeRT generator/loader | No new business service | Generates three kinds of `lib` artifacts from the Host/Client Programs and registers the current environment's artifacts with `ctx.typert` | | API Gateway's Host face | `ctx.typertGateway` | Associates Host definitions with live Services, decodes parameters, resolves receivers, invokes methods, and encodes results | | Connection | `ctx.connection` | Exclusively owns the HTTP Server/future WebSocket, the shared `/api` route, RPC envelope, rpcId, serialization, trust, error transport, TypeRT interception, and legacy API Proxy fallback | -| API Gateway's Client face | `ctx.api` | Mounts Remote contributions, materializes root and scoped APIs, and delegates canonical calls to `ctx.connection.rpc` | +| API Gateway's Client face | `ctx.remote`, `ctx.remote.` | Mounts Remote contributions, materializes each namespace as a traced `remote.` child Service, and delegates canonical calls to `ctx.connection.rpc` | | API Remotes | No new service | Owns Host Agent/Session lookup policy and serves as the only Client business facade, selecting and mounting `/remote` contributions while exposing the selected API declarations | | Agent/Session owning packages | Existing domain services | Provide both static interface merges and runtime lookup/Context providers | | Business packages such as Goal | Existing business Services | Declare only bindings, Remote methods, and canonical DTOs, and export the generated `/remote` subpath | -The Host Gateway does not depend on concrete implementations of `ctx.agents`, `ctx.sessions`, `ctx.goals`, or `ctx.httpServer`. The Client API does not understand the physical carrier, and Connection does not understand Goal, Agent, lookup, `InvocationDescriptor`, or Client API namespaces. +The Host Gateway does not depend on concrete implementations of `ctx.agents`, `ctx.sessions`, `ctx.goals`, or `ctx.httpServer`. The Client Remote does not understand the physical carrier, and Connection does not understand Goal, Agent, lookup, `InvocationDescriptor`, or Remote namespaces. ## Business declarations @@ -121,7 +121,7 @@ The Client also registers an `agent` Context binder. The binder only retrieves a ## InvocationDescriptor -TypeRT, the permissive SRC parser, Host Gateway, and Client API exchange one canonical description: +TypeRT, the permissive SRC parser, Host Gateway, and Client Remote exchange one canonical description: ```text InvocationDescriptor { @@ -141,7 +141,7 @@ InvocationDescriptor { } ``` -`method` is the external short name used by the endpoint and Client API; `implementation` is the actual member name on the Host receiver. `implementation` may be omitted when the two names match. A `direct` descriptor retains the original Service instance as the receiver. A Context descriptor first uses the corresponding Context provider to find the scoped Context, then resolves the receiver by the descriptor's service key. +`method` is the external short name used by the endpoint and Client Remote; `implementation` is the actual member name on the Host receiver. `implementation` may be omitted when the two names match. A `direct` descriptor retains the original Service instance as the receiver. A Context descriptor first uses the corresponding Context provider to find the scoped Context, then resolves the receiver by the descriptor's service key. The strict generator writes `scope` only when a direct method has exactly one lookup parameter, a `TypeRTContextMap` declaration with the same name exists, and both use the same wire type symbol. `scope.wire` must identify that lookup parameter. It declares that a consumer may fill this parameter from the Context in which the call occurs, without changing the Host receiver or endpoint. No scoped projection is generated when there are multiple lookups, no Context declaration, or mismatched wire types; a type mismatch is a build error. @@ -179,9 +179,9 @@ import type { CreateGoalRequest, CreateGoalResult } from '@deepseek-ai/dsh-goal/ Consequently, `SessionId`, the Agent wire ID, the request, and the result all refer to the same TypeScript declaration in the Host and Browser Client. A future TUI can reuse them without a second set of types. Go to Definition, renames, and Find References for a DTO return to the one source location for the business type instead of stopping at a copy in a generated file. -Remote API methods themselves use declaration-map navigation. TypeRT anchors `InvocationModel.location` to the decorated Host method-name token and emits a source-map segment on the corresponding property of the namespace interface. For an adapter-backed endpoint, after the TypeScript editor resolves `ctx.api.models.list` to its generated declaration, `typert.remote-client.d.ts.map` takes it to the Host Service's `remoteExportList` entry point. That entry point explicitly calls the existing, unrenamed `list()` method; the map does not misidentify the decorator, class, or full signature as the method definition. +Remote methods themselves use declaration-map navigation. TypeRT anchors `InvocationModel.location` to the decorated Host method-name token and emits a source-map segment on the corresponding property of the namespace interface. For an adapter-backed endpoint, after the TypeScript editor resolves `ctx.remote.models.list` to its generated declaration, `typert.remote-client.d.ts.map` takes it to the Host Service's `remoteExportList` entry point. That entry point explicitly calls the existing, unrenamed `list()` method; the map does not misidentify the decorator, class, or full signature as the method definition. -TypeRT generates a wire Zod codec for the same symbol key. The Host Gateway uses it to validate input and encode results, while the Client API may use it to encode arguments and validate responses. If a complex type cannot produce a strict codec, the LIB build fails instead of degrading to `unknown` or unchecked JSON. +TypeRT generates a wire Zod codec for the same symbol key. The Host Gateway uses it to validate input and encode results, while the Client Remote uses it to encode arguments and validate responses. If a complex type cannot produce a strict codec, the LIB build fails instead of degrading to `unknown` or unchecked JSON. Named business types referenced by Remote methods must be exported from public, type-only subpaths. If the only reachable entry also imports Host Services, Cordis `Context` merges, or Host-only implementations, the build fails and requires the business package to provide a safe type entry. Primitives, literals, and simple compositions explicitly supported by TypeRT need no additional names. @@ -238,7 +238,7 @@ This import brings the `.d.ts` map augmentation into the current TypeScript proj The business package's published files must include both `lib/typert.remote-client.d.ts.map` and the `src` file referenced by that map. The generated DTS refers to its adjacent map with `//# sourceMappingURL=typert.remote-client.d.ts.map`; the map source points from `lib` to the business source by a relative path such as `../src/index.ts`. The `/remote` export does not list the map separately; the package `files` field publishes it together with the source. -Code that needs only static types may use `import type {} from '@deepseek-ai/dsh-goal/remote'`. This import is erased at runtime, loads no JS, and cannot trigger runtime registration. An environment that makes real calls must pass the contribution from a normal value import to the API Service. +Code that needs only static types may use `import type {} from '@deepseek-ai/dsh-goal/remote'`. This import is erased at runtime, loads no JS, and cannot trigger runtime registration. An environment that makes real calls must pass the contribution from a normal value import to the Client Remote Service. Workspace resolution for `/remote` must explicitly target generated `lib` artifacts and must not let a general package-to-`src` paths rule redirect it to Host source. Ordinary business imports may continue resolving to SRC or LIB according to each environment's existing rules. @@ -275,18 +275,18 @@ interface TypeRTRemoteContextMap { } ``` -`TypeRTRemoteMap` preserves canonical endpoint signatures for protocol typing and reflection. The root API type reads `TypeRTRemoteNamespaceMap` directly instead of deriving methods indirectly through a key-remapped mapped type; the TypeScript Language Service cannot reliably navigate such indirect properties through a declaration map. A namespace interface name encodes the namespace's UTF-8 bytes as hexadecimal, so `goals` deterministically becomes `TypeRTRemoteNamespace$676f616c73`. Different packages generate the same interface name for the same namespace and use module augmentation to merge their methods, while `TypeRTRemoteNamespaceMap.goals` always refers to that one type. +`TypeRTRemoteMap` preserves canonical endpoint signatures for protocol typing and reflection. The root Remote type reads `TypeRTRemoteNamespaceMap` directly instead of deriving methods indirectly through a key-remapped mapped type; the TypeScript Language Service cannot reliably navigate such indirect properties through a declaration map. A namespace interface name encodes the namespace's UTF-8 bytes as hexadecimal, so `goals` deterministically becomes `TypeRTRemoteNamespace$676f616c73`. Different packages generate the same interface name for the same namespace and use module augmentation to merge their methods, while `TypeRTRemoteNamespaceMap.goals` always refers to that one type. TypeRT projects `TypeRTRemoteContextMap` onto a dedicated Scope type according to its Context key. The final programming interface remains: ```text -api.goals.create(agentId, request) -agent.goals.create(request) +ctx.remote.goals.create(agentId, request) +agentCtx.remote.goals.create(request) ``` -The Agent Scope supplies its own `SessionId` automatically. A `@Remote` method with an `agent` lookup can therefore generate both root and scoped consumer signatures. A `@RemoteContext('agent')` method also omits a separate Context identity, but generates only the scoped signature. In this phase, only the Client Agent Context gains `goals`; the Root Context does not. A future TUI must preserve the same Scope restriction. +The Agent Scope supplies its own `SessionId` automatically. A `@Remote` method with an `agent` lookup can therefore generate both root and scoped consumer signatures. A `@RemoteContext('agent')` method also omits a separate Context identity, but generates only the scoped signature. The root `Context` exposes direct namespaces through `ctx.remote`, while `AgentContext.remote` intersects that direct surface with the scoped surface. A future TUI must preserve the same distinction. -`RemoteApi` remains platform-independent, and the Browser Client uses it as its `ClientApi`. If a future TUI reuses this type, it must likewise access it through a dedicated API object and Agent Scope rather than treating the Host `Context` as a broader Service collection. Public Service methods without Remote markers do not enter the Remote maps. +`TypeRTClientRemote` remains platform-independent, and the Browser Client exposes it as `ctx.remote`. If a future TUI reuses this type, it must likewise access it through a dedicated Remote object and Agent Scope rather than treating the Host `Context` as a broader Service collection. Public Service methods without Remote markers do not enter the Remote maps. ## Client TypeRT and the API Gateway Client face @@ -303,39 +303,39 @@ TypeRT.remotes 已导入的 Remote contribution import goalsRemote from '@deepseek-ai/dsh-goal/remote' import sessionsRemote from '@deepseek-ai/dsh-session/remote' -ctx.api.mount(goalsRemote) -ctx.api.mount(sessionsRemote) +await ctx.remote.$mount(goalsRemote) +await ctx.remote.$mount(sessionsRemote) ``` -Client business packages depend only on `@deepseek-ai/dsh-api-remotes/client`, not directly on the API Gateway or the runtime entry of each business `/remote`. API Remotes consumes the shared `TypeRTClientApi` contract and Cordis `ctx.api` service, then re-exports declarations so the selected Remote map reaches business compilation. Adding or removing a complete Client capability changes only this assembly point. +Client business packages depend only on `@deepseek-ai/dsh-api-remotes/client`, not directly on the API Gateway or the runtime entry of each business `/remote`. API Remotes consumes the shared `TypeRTClientRemote` contract and Cordis `ctx.remote` service, then re-exports declarations so the selected Remote map reaches business compilation. Adding or removing a complete Client capability changes only this assembly point. -`ctx.api.mount()` registers a contribution with `TypeRT.remotes`, and its disposer is owned by the Cordis fiber that called the method. Duplicate endpoints, conflicting invocation modes for the same namespace and method, or conflicts between a descriptor and an existing type identity fail immediately. +`ctx.remote.$mount()` registers a contribution with `TypeRT.remotes`, installs its namespace Services and concrete methods, and resolves only after they are ready. Its disposer is owned by the Cordis fiber that called the method. Duplicate endpoints, conflicting invocation modes for the same namespace and method, or conflicts between a descriptor and an existing type identity fail immediately. -The API Service materializes each `@Remote` descriptor as a real function on the root `api`. The function constructs named `args` in descriptor parameter order, applies the Client's strict codec, and then calls `ctx.connection.rpc.call('/api', endpoint, { args }, signal)`. For a cancellation-aware descriptor, the generated function accepts a final optional signal and combines it with the contribution mount lifetime; unmounting therefore cancels every in-flight carrier call, while a caller can cancel one call independently. +The Client Remote Service materializes each `@Remote` descriptor as a real function on a `remote.` child Service. The function constructs named `args` in descriptor parameter order, applies the Client's strict codec, and then calls `ctx.connection.rpc.call('/api', endpoint, { args }, signal)`. For a cancellation-aware descriptor, the generated function accepts a final optional signal and combines it with the contribution mount lifetime; unmounting therefore cancels every in-flight carrier call, while a caller can cancel one call independently. -Neither a direct descriptor with `scope` nor a `@RemoteContext` descriptor copies functions into every Agent Scope. The API Service creates one root singleton Cordis Service for each scoped namespace and materializes methods on that Service. When `agent.goals.create()` is called, the Cordis tracker rebinds the Service's `this.ctx` to the current Agent Context. The method then asks the corresponding Context binder for identity from `this.ctx`. A direct scoped projection substitutes this identity at the lookup position named by `scope.wire`; a Context descriptor writes the identity into the receiver's separate wire field. Both issue the same kind of `/api` call. +Neither a direct descriptor with `scope` nor a `@RemoteContext` descriptor copies functions into every Agent Scope. The Client Remote Service creates one Cordis child Service per namespace, registered as `remote.`, and materializes direct and scoped variants on it. Accessing a method through `agentCtx.remote.goals` captures the current Agent Context before returning the callable handle. The method then asks the corresponding Context binder for identity from that Context. A direct scoped projection substitutes this identity at the lookup position named by `scope.wire`; a Context descriptor writes the identity into the receiver's separate wire field. Both issue the same kind of `/api` call. ```text -root ctx.api.goals.create(agentId, request) +root ctx.remote.goals.create(agentId, request) → direct descriptor → ctx.connection.rpc.call('/api', 'goals/create', { args }) -agent.goals.create(request) - → tracker 将 namespace Service rebind 到 agent Context +agentCtx.remote.goals.create(request) + → remote.goals accessor 捕获 agent Context → agent binder 从 caller Context 取得 agentId → 用 agentId 补入同一 direct descriptor 的 lookup 参数 → ctx.connection.rpc.call('/api', 'goals/create', { args }) ``` -The Root `Context` does not merge the scoped `goals` type; only `AgentContext` gains that property through `RemoteContextApi<'agent'>`. If a caller bypasses the type system and dynamically calls a scoped method from Root, the binder reports an explicit error. If the Client already has a Cordis service with the same name, or two contributions claim the same namespace and method incompatibly, mounting fails instead of overwriting the existing service. +The root `Context` merges only the direct `TypeRTClientRemote` surface. `AgentContext` replaces that property with the intersection of `TypeRTClientRemote` and `TypeRTRemoteContextApi<'agent'>`, so scoped-only methods remain unavailable from root code. If a caller bypasses the type system and dynamically calls a scoped-only method from Root, the binder reports an explicit error. If the Client already has a Cordis service named `remote.`, or two contributions claim the same namespace and method incompatibly, mounting fails instead of overwriting the existing service. -Generated Remote JS contains only descriptors, symbol keys, and codecs; it does not bundle Host Service implementations. The API Service creates real functions from that data, so the runtime does not depend on a JavaScript Proxy. A Proxy remains an implementation option but is not a source of types or reflection. +Generated Remote JS contains only descriptors, symbol keys, and codecs; it does not bundle Host Service implementations. The Client Remote Service creates real functions from that data, so the runtime does not depend on a JavaScript Proxy. A Proxy remains an implementation option but is not a source of types or reflection. ## Cross-environment isomorphism constraints Remote API is a consumer capability, not a synonym for Browser API. The shipped runtime implements Browser Client contribution mounting, Connection RPC calls, and Agent Scope association. -Remote DTS, Remote JS, `RemoteApi`, `InvocationDescriptor`, the Remote RPC data protocol, and Context binders must not depend on the DOM, Browser module loaders, or HTTP. Through Connection, the Browser Client encodes descriptor-materialized methods as `/api` RPC calls. +Remote DTS, Remote JS, `TypeRTClientRemote`, `InvocationDescriptor`, the Remote RPC data protocol, and Context binders must not depend on the DOM, Browser module loaders, or HTTP. Through Connection, the Browser Client encodes descriptor-materialized methods as `/api` RPC calls. A future TUI can join the same call abstraction without changing business decorators, Remote maps, or the shape of API calls. The TUI-visible API must still be generated exclusively from `@Remote` and `@RemoteContext`; sharing a process with the Host must not allow it to bypass Remote restrictions and expose Service methods directly. @@ -421,7 +421,7 @@ The Remote payload is a named JSON object, not a positional array, and does not The complete path is: ```text -ctx.api.goals.create(sessionId, request, signal?) +ctx.remote.goals.create(sessionId, request, signal?) → Client InvocationDescriptor 编码 { args: { agentId, request } } → Client 合并 caller signal 与 contribution mount lifetime → ctx.connection.rpc.call('/api', 'goals/create', { args }, signal) @@ -442,7 +442,7 @@ The Gateway does not handle per-method permissions, caller identity, idempotency ## Connection and protocol boundaries -The API Service owns Remote contributions, method materialization, Scope binding, and the correspondence between positional parameters and descriptors. The Gateway owns Host descriptors, endpoint ownership, lookup, Context, and business invocation. Connection sends `/api`, the endpoint, and `{ args }` as one RPC call to the target and returns the existing RPC result; it does not understand Goal, Agent, lookup, descriptors, or Client API types. +The Client Remote Service owns Remote contributions, namespace Service materialization, Scope binding, and the correspondence between positional parameters and descriptors. The Gateway owns Host descriptors, endpoint ownership, lookup, Context, and business invocation. Connection sends `/api`, the endpoint, and `{ args }` as one RPC call to the target and returns the existing RPC result; it does not understand Goal, Agent, lookup, descriptors, or Client Remote types. The Gateway registers only its ownership matcher and RPC handler with Connection; it does not register an HTTP route. Connection mounts the shared `/api` route into the HTTP Server and gives the bridge one composite FetchHandler; that handler dispatches claimed endpoints to Gateway and unclaimed endpoints to API Proxy. A future Connection transport can preserve this order without changing the Remote payload, business decorators, generated DTS, Remote API types, or Agent Scope programming interface. @@ -451,8 +451,8 @@ The Gateway registers only its ownership matcher and RPC handler with Connection - `@deepseek-ai/dsh-type-meta`: lightweight protocols for decorators, bindings, lookup, Remote Context, and descriptors. - TypeRT generator: analyzes Host/Client Programs, generates local faces and Remote consumer projections, and emits canonical symbol/Zod information. - TypeRT runtime: separately stores the current environment's local reflection and imported Remote contributions. -- `@deepseek-ai/dsh-api-gateway`: its default entry associates Host definitions with Services, claims Remote endpoints, performs lookup, resolves Context receivers, invokes methods, encodes results, and registers an `/api` interceptor with Connection; its `/client` entry mounts Remote contributions, creates strict API methods, and delegates calls to `ctx.connection.rpc`. The entries share the Remote protocol but do not import each other's Cordis interface merges. -- `@deepseek-ai/dsh-api-remotes`: the BFF layer; owns the Host Agent/Session resolver, selects Client `/remote` contributions, and exposes the merged API types to business packages through the shared `TypeRTClientApi` contract. +- `@deepseek-ai/dsh-api-gateway`: its default entry associates Host definitions with Services, claims Remote endpoints, performs lookup, resolves Context receivers, invokes methods, encodes results, and registers an `/api` interceptor with Connection; its `/client` entry mounts Remote contributions, creates strict Remote namespace Services and methods, and delegates calls to `ctx.connection.rpc`. The entries share the Remote protocol but do not import each other's Cordis interface merges. +- `@deepseek-ai/dsh-api-remotes`: the BFF layer; owns the Host Agent/Session resolver, selects Client `/remote` contributions, and exposes the merged Remote types to business packages through the shared `TypeRTClientRemote` contract. - Connection: owns the single HTTP Server/future WebSocket carrier, shared `/api` route and composite FetchHandler, API Proxy fallback, RPC envelope, rpcId, serialization, trust, and error transport. - Business-object packages such as Agent/Session: own lookup, Context providers, canonical ID types, and public type-only entries. - API Proxy Host composition: supplies Web Agent defaults and scope setup to API Remotes and consumes the same `agentFor()` for legacy methods. @@ -460,7 +460,7 @@ The Gateway registers only its ownership matcher and RPC handler with Connection ## Shipped scope and deferred work -The shipped vertical path is `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`. The same direct descriptor with an Agent lookup supports both `ctx.api.goals.create(agentId, request)` and `agentCtx.goals.create(request)`. Ordinary cold sessions are resumed through `agentFor()` during lookup, while subagent-owned identities retain the existing `agent-busy` fence; `@RemoteContext('agent')` remains the distinct scoped-receiver mode. +The shipped vertical path is `@deepseek-ai/dsh-goal/remote → Browser Client Remote → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`. The same direct descriptor with an Agent lookup supports both `ctx.remote.goals.create(agentId, request)` and `agentCtx.remote.goals.create(request)`. Ordinary cold sessions are resumed through `agentFor()` during lookup, while subagent-owned identities retain the existing `agent-busy` fence; `@RemoteContext('agent')` remains the distinct scoped-receiver mode. Connection supplies the shared-channel interceptor and current HTTP carrier mapping. WebSocket migration, the TUI runtime and carrier, TUI Agent Scope wiring, Permission/Approval state machines, Session event streams, call authorization, retries, idempotency, and cross-version protocol compatibility remain outside this decision. @@ -482,7 +482,7 @@ The package topology is `api/remotes → api/gateway → client/connection → h **Generate only Remote DTS, without JS.** Types would work, but the runtime could not enumerate endpoints, codecs, and Context modes without a Proxy or another hand-written registry. The same Host projection therefore emits a Remote JS contribution as well. -**Let a top-level `/remote` import register global state implicitly.** The target Cordis Context may not exist when ESM evaluation occurs, and ownership becomes ambiguous across multiple Contexts, HMR, and disposal. A normal value import therefore returns only a contribution, which the environment assembly explicitly mounts through the API Service. +**Let a top-level `/remote` import register global state implicitly.** The target Cordis Context may not exist when ESM evaluation occurs, and ownership becomes ambiguous across multiple Contexts, HMR, and disposal. A normal value import therefore returns only a contribution, which the environment assembly explicitly mounts through the Client Remote Service. **Create a separate transport, HTTP route, or `/api2` channel for Remote.** This would duplicate or split Connection's Server ownership, rpcId, serialization, trust, errors, and future WebSocket lifecycle. The shared `/api` interceptor instead keeps one physical route and lets Connection preserve API Proxy as the fallback FetchHandler. @@ -490,7 +490,7 @@ The package topology is `api/remotes → api/gateway → client/connection → h - Goal Service directly decorates mutation methods whose business signatures already match the Remote contract and keeps `remoteExportCreate(...)` only to adapt `GoalView` into `CreateGoalResult`, without a second route, codec, or Client method list. - A clean `build:lib` emits Host and consumer Remote artifacts before Client compilation, including the business package's JS, DTS, and declaration map under `/remote`. -- Importing `@deepseek-ai/dsh-goal/remote` adds the strict `api.goals.create(...)` type and declaration navigation to `remoteExportCreate`; omitting that import omits the namespace. +- Importing `@deepseek-ai/dsh-goal/remote` adds the strict `ctx.remote.goals.create(...)` type and declaration navigation to `remoteExportCreate`; omitting that import omits the namespace. - Mounting the same import's JS contribution supplies endpoint, parameter, result, lookup, Context, and Zod reflection and materializes the call without a handwritten stub. - Root and Agent-scoped calls cross the real shared `/api` carrier, resolve `agentId` to the live Agent, invoke the original Goal receiver, and return through the existing RPC envelope. - Agent and Session lookups share a single in-flight cold-session resume; ordinary cold sessions receive restored objects, while both cold and live subagent identities return `agent-busy` before business invocation. @@ -509,13 +509,13 @@ The permissive SRC descriptor does not validate the internal structure of ordina Canonical public types require business DTOs to have type-only entries, which may expose packages whose Host types and implementation entries are currently mixed. The build rejects those boundaries instead of copying types to conceal them. -Type imports and runtime contributions have different effects. `import type {}` extends only the static API. If a real calling environment omits the value contribution, the API Service must fail with an explicit "Remote not mounted" error. +Type imports and runtime contributions have different effects. `import type {}` extends only the static Remote surface. If a real calling environment omits the value contribution, the Client Remote Service must fail with an explicit "Remote not mounted" error. Browser and Host each hold their own Zod instances and cannot compare object identities across realms. Consistency is guaranteed only by canonical symbol keys, the same generated model, and wire behavior. A consumer may import a Remote contract that is not currently mounted on the Host. The types mean "this protocol capability was selected by the consumer," not that a corresponding Service currently exists in the target process; an unavailable endpoint must fail explicitly at runtime. -Connection's general channel API must suit both the current HTTP carrier and a future WebSocket carrier. If the API exposes `fetch`, an HTTP request, or a route handle to the Gateway/API Service, WebSocket migration will pierce the Remote layer again. Those physical objects must therefore remain internal to Connection. +Connection's general channel API must suit both the current HTTP carrier and a future WebSocket carrier. If the Client Remote or Gateway exposes `fetch`, an HTTP request, or a route handle, WebSocket migration will pierce the Remote layer again. Those physical objects must therefore remain internal to Connection. Remote endpoints use Connection's `trusted-host` authority. Loopback is accepted by default and LAN callers require an explicit trusted-host configuration, but this layer adds no per-method caller authorization; every trusted host can invoke a mounted Remote endpoint. diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index 3d6ec680ba..f1b7e5f9c6 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -18,11 +18,11 @@ Host 与 Browser Client 使用独立的 TypeScript Program,因为两边会以 业务 Service 继承 `GatewayService`,并通过 `@Remote` 或 `@RemoteContext()` 声明可调用方法;已有其他基类的 Service 可以改用 `bindTypeRTGateway()` 暴露同一绑定。TypeRT 从 Host Program 生成 Host 本地反射产物和平台无关的 Remote 消费端投影;Client Program 继续独立生成自己的本地反射产物。 -Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只暴露被 Remote decorator 标记的方法,并引用业务包唯一的公共类型符号;`.d.ts.map` 把消费端 API 方法导航回 Host 业务方法实现;`.js` 携带同一契约的 endpoint、参数、Context 和 Zod 信息。Browser Client 在 assembly 层把需要的 Remote JS 贡献集中挂到 Client API Service;该投影和 API 抽象保持平台无关,以便未来 TUI 复用。 +Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只暴露被 Remote decorator 标记的方法,并引用业务包唯一的公共类型符号;`.d.ts.map` 把消费端 API 方法导航回 Host 业务方法实现;`.js` 携带同一契约的 endpoint、参数、Context 和 Zod 信息。Browser Client 在 assembly 层把需要的 Remote JS 贡献集中挂到 Client Remote Service;该投影和 Remote 抽象保持平台无关,以便未来 TUI 复用。 -`@deepseek-ai/dsh-api-gateway` 位于 `packages/api/gateway`,提供对称的两个 face:默认入口提供 Host `ctx.typertGateway`,`/client` 入口提供消费端 `ctx.api`。两边各自在本地消费由同一模型生成的 `InvocationDescriptor`,descriptor 不通过 wire 发送。Remote 数据协议运行在 Connection 共享的 `/api` RPC channel 上;业务调用界面不随 Connection 从 HTTP 迁移到 WebSocket 而改变。 +`@deepseek-ai/dsh-api-gateway` 位于 `packages/api/gateway`,提供对称的两个 face:默认入口提供 Host `ctx.typertGateway`,`/client` 入口提供消费端 `ctx.remote`。两边各自在本地消费由同一模型生成的 `InvocationDescriptor`,descriptor 不通过 wire 发送。Remote 数据协议运行在 Connection 共享的 `/api` RPC channel 上;业务调用界面不随 Connection 从 HTTP 迁移到 WebSocket 而改变。 -`@deepseek-ai/dsh-api-remotes` 位于 `packages/api/remotes`,是 Gateway 上层的 BFF 层。其 Host 入口负责 Agent/Session 身份解析与 TypeRT lookup 配置;`/client` 入口选择应用对外暴露的生成 Remote contribution。Client 入口通过 Cordis 消费共享的 `TypeRTClientApi` 契约,而不导入具体 Gateway 实现。 +`@deepseek-ai/dsh-api-remotes` 位于 `packages/api/remotes`,是 Gateway 上层的 BFF 层。其 Host 入口负责 Agent/Session 身份解析与 TypeRT lookup 配置;`/client` 入口选择应用对外暴露的生成 Remote contribution。Client 入口通过 Cordis 消费共享的 `TypeRTClientRemote` 契约,而不导入具体 Gateway 实现。 ## 组件和 Cordis 服务 @@ -33,12 +33,12 @@ Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只 | TypeRT generator/loader | 无新增业务服务 | 从 Host/Client Program 生成三类 `lib` 产物,并把当前环境产物注册到 `ctx.typert` | | API Gateway 的 Host face | `ctx.typertGateway` | 关联 Host definition 与活 Service,解码参数、解析 receiver、调用方法和编码结果 | | Connection | `ctx.connection` | 独占 HTTP Server/未来 WebSocket、共享 `/api` route、RPC envelope、rpcId、序列化、trust、错误传输、TypeRT 拦截和旧 API Proxy 回退 | -| API Gateway 的 Client face | `ctx.api` | mount Remote contribution,实体化根 API 和 scoped API,把规范调用交给 `ctx.connection.rpc` | +| API Gateway 的 Client face | `ctx.remote`、`ctx.remote.` | mount Remote contribution,把每个 namespace 实体化为可追踪的 `remote.` 子 Service,并把规范调用交给 `ctx.connection.rpc` | | API Remotes | 无新增服务 | 负责 Host Agent/Session lookup 策略,并作为 Client 业务的唯一 facade,选择并挂载 `/remote` contribution,同时暴露所选 API 声明 | | Agent/Session owning 包 | 既有领域服务 | 同时提供静态 interface merge 与运行时 lookup/Context provider | | Goal 等业务包 | 既有业务 Service | 只声明 binding、Remote 方法和唯一 DTO,并导出生成的 `/remote` 子路径 | -Host Gateway 不依赖 `ctx.agents`、`ctx.sessions`、`ctx.goals` 或 `ctx.httpServer` 的具体实现。Client API 不理解物理 carrier,Connection 也不理解 Goal、Agent、lookup、`InvocationDescriptor` 或 Client API namespace。 +Host Gateway 不依赖 `ctx.agents`、`ctx.sessions`、`ctx.goals` 或 `ctx.httpServer` 的具体实现。Client Remote 不理解物理 carrier,Connection 也不理解 Goal、Agent、lookup、`InvocationDescriptor` 或 Remote namespace。 ## 业务声明 @@ -121,7 +121,7 @@ Client 侧也注册 `agent` Context binder。binder 只负责从一次调用所 ## InvocationDescriptor -TypeRT、SRC 弱解析器、Host Gateway 和 Client API 之间只交换一种规范描述: +TypeRT、SRC 弱解析器、Host Gateway 和 Client Remote 之间只交换一种规范描述: ```text InvocationDescriptor { @@ -141,7 +141,7 @@ InvocationDescriptor { } ``` -`method` 是 endpoint 和 Client API 使用的外部短名,`implementation` 是 Host receiver 上的真实成员名;两者相同时可省略 `implementation`。`direct` descriptor 保留原始 Service 实例作为 receiver。Context descriptor 先通过对应 Context provider 找到 scoped Context,再以 descriptor 的 service key 解析 receiver。 +`method` 是 endpoint 和 Client Remote 使用的外部短名,`implementation` 是 Host receiver 上的真实成员名;两者相同时可省略 `implementation`。`direct` descriptor 保留原始 Service 实例作为 receiver。Context descriptor 先通过对应 Context provider 找到 scoped Context,再以 descriptor 的 service key 解析 receiver。 严格生成器只在 direct 方法恰好包含一个 lookup 参数、同名 `TypeRTContextMap` 声明存在且两者使用同一 wire 类型 symbol 时写入 `scope`。`scope.wire` 必须指向该 lookup 参数;它声明消费端可以从调用所在 Context 补入这个参数,不改变 Host receiver 或 endpoint。多个 lookup、缺少 Context 声明或 wire 类型不一致时不生成 scoped 投影,其中类型不一致属于构建错误。 @@ -179,9 +179,9 @@ import type { CreateGoalRequest, CreateGoalResult } from '@deepseek-ai/dsh-goal/ 因此 `SessionId`、Agent wire ID、request 和 result 在 Host 与 Browser Client 中都指向同一 TypeScript declaration,未来 TUI 复用时也不需要第二份类型。DTO 的跳转定义、重命名和引用查找回到业务类型的唯一源码位置,而不是停在生成文件中的副本。 -Remote API 方法本身使用 declaration map 导航。TypeRT 把 `InvocationModel.location` 固定在 Host 被装饰方法的方法名 token,并在 namespace interface 的对应属性上写入 source-map segment。对于由适配器支撑的 endpoint,TypeScript editor 从 `ctx.api.models.list` 取得生成 declaration 后,再沿 `typert.remote-client.d.ts.map` 跳到 Host Service 的 `remoteExportList` 远程出口。该出口继续显式调用不改名的存量 `list()`,map 不把 decorator、class 或整个签名误当成方法定义位置。 +Remote 方法本身使用 declaration map 导航。TypeRT 把 `InvocationModel.location` 固定在 Host 被装饰方法的方法名 token,并在 namespace interface 的对应属性上写入 source-map segment。对于由适配器支撑的 endpoint,TypeScript editor 从 `ctx.remote.models.list` 取得生成 declaration 后,再沿 `typert.remote-client.d.ts.map` 跳到 Host Service 的 `remoteExportList` 远程出口。该出口继续显式调用不改名的存量 `list()`,map 不把 decorator、class 或整个签名误当成方法定义位置。 -TypeRT 为同一 symbol key 生成 wire Zod codec。Host Gateway 用它校验输入和编码结果,Client API 可以用它编码参数并校验响应;复杂类型无法生成严格 codec 时,LIB 构建失败,不降级为 `unknown` 或无校验 JSON。 +TypeRT 为同一 symbol key 生成 wire Zod codec。Host Gateway 用它校验输入和编码结果,Client Remote 用它编码参数并校验响应;复杂类型无法生成严格 codec 时,LIB 构建失败,不降级为 `unknown` 或无校验 JSON。 Remote 方法引用的命名业务类型必须从纯类型公共 subpath 导出。如果唯一可达入口会带入 Host Service、Cordis `Context` merge 或 Host-only 实现,构建失败并要求业务包提供安全的类型出口。原始值、字面量和 TypeRT 明确支持的简单组合不需要额外命名。 @@ -238,7 +238,7 @@ import goalsRemote from '@deepseek-ai/dsh-goal/remote' 业务 package 的发布文件必须同时包含 `lib/typert.remote-client.d.ts.map` 和 map 指向的 `src` 文件。生成 DTS 以 `//# sourceMappingURL=typert.remote-client.d.ts.map` 引用相邻 map;map 中的 source 从 `lib` 相对指向业务源码,例如 `../src/index.ts`。`/remote` export 不单独列出 map,package `files` 负责把它与源码一起发布。 -仅需要静态类型时可以使用 `import type {} from '@deepseek-ai/dsh-goal/remote'`;这种 import 在运行时会被擦除,不会加载 JS,也不能触发任何运行时注册。需要真实调用的环境必须把普通 value import 得到的 contribution 交给 API Service。 +仅需要静态类型时可以使用 `import type {} from '@deepseek-ai/dsh-goal/remote'`;这种 import 在运行时会被擦除,不会加载 JS,也不能触发任何运行时注册。需要真实调用的环境必须把普通 value import 得到的 contribution 交给 Client Remote Service。 workspace 对 `/remote` 的解析必须明确指向 `lib` 生成物,不能被通用 package-to-`src` paths 规则带回 Host 源码。普通业务 import 仍可按各环境既有规则解析到 SRC 或 LIB。 @@ -275,18 +275,18 @@ interface TypeRTRemoteContextMap { } ``` -`TypeRTRemoteMap` 保留规范 endpoint 签名,供协议类型和反射使用。根 API 类型直接读取 `TypeRTRemoteNamespaceMap`,不通过 key-remapped mapped type 间接推导方法;TypeScript Language Service 无法把这种间接属性稳定导航到 declaration map。namespace interface 名由 namespace 的 UTF-8 bytes 编成 hex,`goals` 因而稳定得到 `TypeRTRemoteNamespace$676f616c73`。不同 package 对同一 namespace 生成同名 interface,依靠 module augmentation 合并各自方法,且 `TypeRTRemoteNamespaceMap.goals` 始终引用同一类型。 +`TypeRTRemoteMap` 保留规范 endpoint 签名,供协议类型和反射使用。根 Remote 类型直接读取 `TypeRTRemoteNamespaceMap`,不通过 key-remapped mapped type 间接推导方法;TypeScript Language Service 无法把这种间接属性稳定导航到 declaration map。namespace interface 名由 namespace 的 UTF-8 bytes 编成 hex,`goals` 因而稳定得到 `TypeRTRemoteNamespace$676f616c73`。不同 package 对同一 namespace 生成同名 interface,依靠 module augmentation 合并各自方法,且 `TypeRTRemoteNamespaceMap.goals` 始终引用同一类型。 TypeRT 把 `TypeRTRemoteContextMap` 按 Context key 投影到专用 Scope 类型。最终编程界面保持: ```text -api.goals.create(agentId, request) -agent.goals.create(request) +ctx.remote.goals.create(agentId, request) +agentCtx.remote.goals.create(request) ``` -Agent Scope 自动提供自己的 `SessionId`。因此带 `agent` lookup 的 `@Remote` 方法可以同时生成 root 和 scoped 两种消费端签名;`@RemoteContext('agent')` 方法也省略独立的 Context identity,但只生成 scoped 签名。本期只有 Client Agent Context 获得 `goals`,Root Context 不获得该属性;未来 TUI 复用时必须维持相同的 Scope 限制。 +Agent Scope 自动提供自己的 `SessionId`。因此带 `agent` lookup 的 `@Remote` 方法可以同时生成 root 和 scoped 两种消费端签名;`@RemoteContext('agent')` 方法也省略独立的 Context identity,但只生成 scoped 签名。根 `Context` 通过 `ctx.remote` 暴露 direct namespace,`AgentContext.remote` 则把该 direct surface 与 scoped surface 取交集。未来 TUI 复用时必须维持相同区分。 -`RemoteApi` 保持平台无关,Browser Client 把它作为自己的 `ClientApi`。未来 TUI 若复用该类型,也必须通过专用 API 对象和 Agent Scope 使用它,不能把 Host `Context` 当成更宽的 Service 集合;未标记的 public Service 方法不会进入 Remote maps。 +`TypeRTClientRemote` 保持平台无关,Browser Client 通过 `ctx.remote` 暴露它。未来 TUI 若复用该类型,也必须通过专用 Remote 对象和 Agent Scope 使用它,不能把 Host `Context` 当成更宽的 Service 集合;未标记的 public Service 方法不会进入 Remote maps。 ## Client TypeRT 与 API Gateway Client face @@ -303,39 +303,39 @@ TypeRT.remotes 已导入的 Remote contribution import goalsRemote from '@deepseek-ai/dsh-goal/remote' import sessionsRemote from '@deepseek-ai/dsh-session/remote' -ctx.api.mount(goalsRemote) -ctx.api.mount(sessionsRemote) +await ctx.remote.$mount(goalsRemote) +await ctx.remote.$mount(sessionsRemote) ``` -Client 业务包只引用 `@deepseek-ai/dsh-api-remotes/client`,不直接依赖 API Gateway 或各业务 `/remote` 运行时入口。API Remotes 消费共享的 `TypeRTClientApi` 契约和 Cordis `ctx.api` 服务,再重新导出声明,使所选 Remote map 进入业务编译;新增或移除整套 Client 能力只修改这一处 assembly。 +Client 业务包只引用 `@deepseek-ai/dsh-api-remotes/client`,不直接依赖 API Gateway 或各业务 `/remote` 运行时入口。API Remotes 消费共享的 `TypeRTClientRemote` 契约和 Cordis `ctx.remote` 服务,再重新导出声明,使所选 Remote map 进入业务编译;新增或移除整套 Client 能力只修改这一处 assembly。 -`ctx.api.mount()` 把 contribution 注册到 `TypeRT.remotes`,并由调用该方法的 Cordis fiber 持有 disposer。endpoint 重复、同一 namespace/method 模式冲突或 descriptor 与现有类型身份冲突时直接失败。 +`ctx.remote.$mount()` 把 contribution 注册到 `TypeRT.remotes`,安装它的 namespace Service 和具体方法,并在它们就绪后才 resolve。调用该方法的 Cordis fiber 持有 disposer。endpoint 重复、同一 namespace/method 模式冲突或 descriptor 与现有类型身份冲突时直接失败。 -API Service 把 `@Remote` descriptor 实体化为根 `api` 上的真实函数。函数按 descriptor 的位置参数顺序构造具名 `args`,执行 Client strict codec,然后调用 `ctx.connection.rpc.call('/api', endpoint, { args }, signal)`。对于支持取消的 descriptor,生成的函数接受最后一个可选 signal,并将其与 contribution 的挂载生命周期合并;因此卸载会取消所有正在进行的 carrier 调用,而调用方也可以单独取消一次调用。 +Client Remote Service 把 `@Remote` descriptor 实体化为 `remote.` 子 Service 上的真实函数。函数按 descriptor 的位置参数顺序构造具名 `args`,执行 Client strict codec,然后调用 `ctx.connection.rpc.call('/api', endpoint, { args }, signal)`。对于支持取消的 descriptor,生成的函数接受最后一个可选 signal,并将其与 contribution 的挂载生命周期合并;因此卸载会取消所有正在进行的 carrier 调用,而调用方也可以单独取消一次调用。 -带 `scope` 的 direct descriptor 和 `@RemoteContext` descriptor 都不为每个 Agent Scope 复制函数。API Service 为每个 scoped namespace 建立一个 root singleton Cordis Service,并在该 Service 上实体化方法;Cordis tracker 在 `agent.goals.create()` 调用时把 Service 的 `this.ctx` rebind 到当前 Agent Context。方法再通过对应 Context binder 从 `this.ctx` 取得 identity。direct scoped 投影用 identity 替代 `scope.wire` 指定的 lookup 位置,Context descriptor 则把 identity 写入 receiver 的独立 wire 字段;两者都发起同一种 `/api` 调用。 +带 `scope` 的 direct descriptor 和 `@RemoteContext` descriptor 都不为每个 Agent Scope 复制函数。Client Remote Service 为每个 namespace 创建一个注册为 `remote.` 的 Cordis 子 Service,并在其上实体化 direct 与 scoped 变体。通过 `agentCtx.remote.goals` 取得方法时,accessor 会在返回可调用句柄前捕获当前 Agent Context。方法再通过对应 Context binder 从该 Context 取得 identity。direct scoped 投影用 identity 替代 `scope.wire` 指定的 lookup 位置,Context descriptor 则把 identity 写入 receiver 的独立 wire 字段;两者都发起同一种 `/api` 调用。 ```text -root ctx.api.goals.create(agentId, request) +root ctx.remote.goals.create(agentId, request) → direct descriptor → ctx.connection.rpc.call('/api', 'goals/create', { args }) -agent.goals.create(request) - → tracker 将 namespace Service rebind 到 agent Context +agentCtx.remote.goals.create(request) + → remote.goals accessor 捕获 agent Context → agent binder 从 caller Context 取得 agentId → 用 agentId 补入同一 direct descriptor 的 lookup 参数 → ctx.connection.rpc.call('/api', 'goals/create', { args }) ``` -Root `Context` 不 merge scoped `goals` 类型;只有 `AgentContext` 通过 `RemoteContextApi<'agent'>` 获得该属性。若调用方绕过类型从 Root 动态调用 scoped 方法,binder 明确报错。若 Client 已有同名 Cordis service,或两个 contribution 冲突占用同一 namespace/method,mount 直接失败,不覆盖现有服务。 +根 `Context` 只 merge direct `TypeRTClientRemote` surface;`AgentContext` 把该属性替换为 `TypeRTClientRemote` 与 `TypeRTRemoteContextApi<'agent'>` 的交叉,因而 scoped-only 方法不会暴露给 root 代码。若调用方绕过类型从 Root 动态调用 scoped-only 方法,binder 明确报错。若 Client 已有名为 `remote.` 的 Cordis service,或两个 contribution 冲突占用同一 namespace/method,mount 直接失败,不覆盖现有服务。 -生成的 Remote JS 只包含 descriptor、symbol key 和 codec,不打包 Host Service 实现。API Service 据此创建真实函数,因此运行时不依赖 JavaScript Proxy;Proxy 可以作为实现选择,但不会成为类型或反射来源。 +生成的 Remote JS 只包含 descriptor、symbol key 和 codec,不打包 Host Service 实现。Client Remote Service 据此创建真实函数,因此运行时不依赖 JavaScript Proxy;Proxy 可以作为实现选择,但不会成为类型或反射来源。 ## 跨环境同构约束 Remote API 是消费端能力,不等同于 Browser API。已交付的运行时实现 Browser Client contribution 挂载、Connection RPC 调用和 Agent Scope 关联。 -Remote DTS、Remote JS、`RemoteApi`、`InvocationDescriptor`、Remote RPC 数据协议和 Context binder 不得依赖 DOM、Browser module loader 或 HTTP。Browser Client 通过 Connection 把 descriptor 实体化的方法编码为 `/api` RPC 调用。 +Remote DTS、Remote JS、`TypeRTClientRemote`、`InvocationDescriptor`、Remote RPC 数据协议和 Context binder 不得依赖 DOM、Browser module loader 或 HTTP。Browser Client 通过 Connection 把 descriptor 实体化的方法编码为 `/api` RPC 调用。 未来 TUI 可以在不改变业务 decorator、Remote maps 和 API 调用形状的前提下接入同一调用抽象。届时 TUI 可见的 API 仍只能由 `@Remote` 和 `@RemoteContext` 生成,不能因为它与 Host 同进程就绕过 Remote 限制直接暴露 Service 方法。 @@ -421,7 +421,7 @@ Remote payload 使用具名 JSON 对象,不使用位置数组,也不发送 ` 完整链路为: ```text -ctx.api.goals.create(sessionId, request, signal?) +ctx.remote.goals.create(sessionId, request, signal?) → Client InvocationDescriptor 编码 { args: { agentId, request } } → Client 合并 caller signal 与 contribution mount lifetime → ctx.connection.rpc.call('/api', 'goals/create', { args }, signal) @@ -442,7 +442,7 @@ Gateway 不处理逐方法权限、调用者身份、幂等或长连接状态。 ## Connection 与协议边界 -API Service 负责 Remote contribution、方法实体化、Scope 绑定以及位置参数与 descriptor 的对应。Gateway 负责 Host descriptor、endpoint ownership、lookup、Context 和业务调用。Connection 把 `/api`、endpoint 和 `{ args }` 作为一个 RPC 调用发送到目标并返回既有 RPC result;它不理解 Goal、Agent、lookup、descriptor 或 Client API 类型。 +Client Remote Service 负责 Remote contribution、namespace Service 实体化、Scope 绑定以及位置参数与 descriptor 的对应。Gateway 负责 Host descriptor、endpoint ownership、lookup、Context 和业务调用。Connection 把 `/api`、endpoint 和 `{ args }` 作为一个 RPC 调用发送到目标并返回既有 RPC result;它不理解 Goal、Agent、lookup、descriptor 或 Client Remote 类型。 Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 HTTP route。Connection 把共享 `/api` route 挂到 HTTP Server,并把一个复合 FetchHandler 交给 bridge;该 handler 将已认领 endpoint 分发给 Gateway,未认领 endpoint 则交给 API Proxy。未来 Connection transport 可以保留相同顺序,而不改变 Remote payload、业务 decorator、生成的 DTS、Remote API 类型或 Agent Scope 编程界面。 @@ -451,8 +451,8 @@ Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 H - `@deepseek-ai/dsh-type-meta`:轻量 decorator、binding、lookup、Remote Context 和 descriptor 协议。 - TypeRT generator:分析 Host/Client Program,生成本地 face 和 Remote 消费端投影,并生成规范 symbol/Zod 信息。 - TypeRT runtime:分别保存当前环境的 local reflection 与导入的 Remote contribution。 -- `@deepseek-ai/dsh-api-gateway`:默认入口关联 Host definition 与 Service,认领 Remote endpoint,执行 lookup、Context receiver 解析、调用和结果编码,并向 Connection 注册 `/api` interceptor;`/client` 入口挂载 Remote contribution,创建严格 API 方法,并把调用交给 `ctx.connection.rpc`。两个入口共享 Remote 协议,但不互相导入各自的 Cordis interface merge。 -- `@deepseek-ai/dsh-api-remotes`:BFF 层;负责 Host Agent/Session resolver,选择 Client `/remote` contribution,并通过共享的 `TypeRTClientApi` 契约向业务包暴露合并后的 API 类型。 +- `@deepseek-ai/dsh-api-gateway`:默认入口关联 Host definition 与 Service,认领 Remote endpoint,执行 lookup、Context receiver 解析、调用和结果编码,并向 Connection 注册 `/api` interceptor;`/client` 入口挂载 Remote contribution,创建严格 Remote namespace Service 和方法,并把调用交给 `ctx.connection.rpc`。两个入口共享 Remote 协议,但不互相导入各自的 Cordis interface merge。 +- `@deepseek-ai/dsh-api-remotes`:BFF 层;负责 Host Agent/Session resolver,选择 Client `/remote` contribution,并通过共享的 `TypeRTClientRemote` 契约向业务包暴露合并后的 Remote 类型。 - Connection:拥有唯一 HTTP Server/未来 WebSocket carrier、共享 `/api` route 与复合 FetchHandler、API Proxy 回退、RPC envelope、rpcId、序列化、trust 和错误传输。 - Agent/Session 等业务对象包:拥有 lookup、Context provider、唯一 ID 类型和纯类型公共出口。 - API Proxy Host 组合:向 API Remotes 提供 Web Agent 默认值和 scope 设置,并让旧方法使用同一个 `agentFor()`。 @@ -460,7 +460,7 @@ Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 H ## 已交付范围与后续工作 -已交付的纵向链路是 `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`。同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.api.goals.create(agentId, request)` 与 `agentCtx.goals.create(request)`。普通冷会话在 lookup 时通过 `agentFor()` 恢复,subagent-owned identity 保持既有 `agent-busy` fence;`@RemoteContext('agent')` 仍是独立的 scoped receiver 模式。 +已交付的纵向链路是 `@deepseek-ai/dsh-goal/remote → Browser Client Remote → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`。同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.remote.goals.create(agentId, request)` 与 `agentCtx.remote.goals.create(request)`。普通冷会话在 lookup 时通过 `agentFor()` 恢复,subagent-owned identity 保持既有 `agent-busy` fence;`@RemoteContext('agent')` 仍是独立的 scoped receiver 模式。 Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebSocket 迁移、TUI runtime 与 carrier、TUI Agent Scope 接线、Permission/Approval 状态机、Session 事件流、调用授权、重试、幂等及跨版本协议兼容均不属于本决策。 @@ -482,7 +482,7 @@ Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebS **只生成 Remote DTS,不生成 JS。** 类型可以成立,但运行时无法枚举 endpoint、codec 和 Context 模式,只能依赖 Proxy 或另一份手写注册表,因此同一次 Host 投影同时生成 Remote JS contribution。 -**让 `/remote` 的顶层 import 偷偷注册全局状态。** ESM 求值时未必已有目标 Cordis Context,多个 Context、HMR 和 dispose 也无法明确归属,因此普通 value import 只返回 contribution,由环境 assembly 的 API Service 显式挂载。 +**让 `/remote` 的顶层 import 偷偷注册全局状态。** ESM 求值时未必已有目标 Cordis Context,多个 Context、HMR 和 dispose 也无法明确归属,因此普通 value import 只返回 contribution,由环境 assembly 的 Client Remote Service 显式挂载。 **为 Remote 新建独立 transport、HTTP route 或 `/api2` channel。** 这会复制或拆分 Connection 的 Server ownership、rpcId、序列化、trust、错误和未来 WebSocket 生命周期。共享 `/api` interceptor 保留唯一物理 route,并让 Connection 继续以 API Proxy 作为回退 FetchHandler。 @@ -490,7 +490,7 @@ Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebS - Goal Service 直接装饰业务签名已经符合 Remote 契约的变更类方法,仅保留 `remoteExportCreate(...)` 把 `GoalView` 适配为 `CreateGoalResult`,无需第二条路由、第二份 codec 或 Client 方法清单。 - 一次干净的 `build:lib` 会在 Client 编译前生成 Host 与消费方 Remote 产物,包括业务包 `/remote` 下的 JS、DTS 和 declaration map。 -- 导入 `@deepseek-ai/dsh-goal/remote` 会加入严格的 `api.goals.create(...)` 类型,并可通过 declaration 导航到 `remoteExportCreate`;不导入时不会出现该 namespace。 +- 导入 `@deepseek-ai/dsh-goal/remote` 会加入严格的 `ctx.remote.goals.create(...)` 类型,并可通过 declaration 导航到 `remoteExportCreate`;不导入时不会出现该 namespace。 - 挂载同一次 import 得到的 JS contribution 会提供 endpoint、参数、结果、lookup、Context 和 Zod 反射,并在无需手写 stub 的情况下实体化调用。 - Root 与 Agent-scoped 调用会经过真实的共享 `/api` carrier,将 `agentId` 解析为活 Agent,调用原始 Goal receiver,并通过既有 RPC envelope 返回。 - Agent 与 Session lookup 会共享同一次并发冷恢复;普通冷会话得到恢复后的对象,冷态或 live subagent identity 均在业务调用前返回 `agent-busy`。 @@ -509,13 +509,13 @@ SRC 弱 descriptor 不验证普通 JSON 内部结构。Host Remote 签名变化 公共类型唯一性要求业务 DTO 具有纯类型出口,可能暴露现有包中 Host 类型与实现入口混杂的问题。构建会拒绝这些边界,而不是复制类型掩盖问题。 -类型 import 与运行时 contribution 是两种不同效果。`import type {}` 只扩展静态 API;真实调用环境遗漏 value contribution 时,API Service 必须以明确的“Remote 未挂载”错误失败。 +类型 import 与运行时 contribution 是两种不同效果。`import type {}` 只扩展静态 Remote surface;真实调用环境遗漏 value contribution 时,Client Remote Service 必须以明确的“Remote 未挂载”错误失败。 Browser 与 Host 各自持有 Zod 实例,不能依赖对象 identity 跨 realm 比较;一致性只由规范 symbol key、同一生成模型和 wire 行为保证。 消费端可以导入 Host 当前未挂载的 Remote contract。类型表示“该协议能力已被消费端选择”,不保证目标进程当前存在对应 Service;运行时 endpoint 不可用必须明确失败。 -Connection 的通用 channel API 必须同时适合当前 HTTP carrier 和后续 WebSocket carrier。若接口把 `fetch`、HTTP request 或 route handle 暴露给 Gateway/API Service,WebSocket 迁移会再次穿透 Remote 层,因此这些物理对象必须留在 Connection 内部。 +Connection 的通用 channel API 必须同时适合当前 HTTP carrier 和后续 WebSocket carrier。若 Client Remote 或 Gateway 暴露 `fetch`、HTTP request 或 route handle,WebSocket 迁移会再次穿透 Remote 层,因此这些物理对象必须留在 Connection 内部。 Remote endpoint 使用 Connection 的 `trusted-host` authority。系统默认接受 loopback;LAN 调用方必须通过显式 trusted-host 配置接入,但本层不增加逐方法调用方授权,因此每个 trusted host 都能调用已挂载的 Remote endpoint。 diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index 05038eb8b9..d07272c182 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: 090758d58306d5ea806567f0de710a1c1f5ed747 -api-gateway.zh.md: 9d7286b6b86918f3bc1e7a6cdd9bdf04447abc57 +api-gateway.md: 90aa661cc86a4f419e173560c55511c969182990 +api-gateway.zh.md: 6fcbb562b204e71d00833042ee0632bda0217940 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 090758d583..90aa661cc8 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -6,7 +6,7 @@ This is the current-state reference for the TypeRT API Gateway. It describes how ## Programming model -Business services use `@Remote` or `@RemoteContext` to select the methods exposed to the Client. Unmarked methods do not enter the generated Client types or runtime contributions and cannot be called through `ctx.api`. +Business services use `@Remote` or `@RemoteContext` to select the methods exposed to the Client. Unmarked methods do not enter the generated Client types or runtime contributions and cannot be called through `ctx.remote`. `@Remote` denotes calling a Cordis service registered on the root Host Context. Complex Host objects cannot cross the wire directly; the business package must declare their association with a wire identity through `TypeRTLookupMap` and register a default resolution provider with `ctx.typert.lookups` at runtime. For example, an `Agent` parameter named `agent` in the Host signature produces an `agentId` wire field, and the Gateway resolves that id to a Host object before invoking the business method. Host composition can use `ctx.typert.lookups.configure()` to override the resolution policy for a lookup key without changing the parameter name, wire field, or canonical type symbol owned by the business package. @@ -55,7 +55,7 @@ export class GoalService extends GatewayService { Remote methods may return a value synchronously or return a Promise. For cooperative cancellation, the final parameter in the Host signature must be `signal: AbortSignal` using the global type; it is recorded in the descriptor instead of entering `args`, while the generated Client method accepts an optional final `AbortSignal`. -The Client uses concrete functions on ordinary objects, not a JavaScript Proxy. Direct Remotes appear under `ctx.api.`; when an `@Remote` method has exactly one lookup parameter and a same-named `TypeRTContextMap` uses the same wire identity, the generator also projects the method without that identity parameter onto the corresponding scoped Context. `@RemoteContext` generates only the scoped invocation interface. +The Client uses concrete functions on ordinary objects, not a JavaScript Proxy. Direct and scoped calls appear under `ctx.remote.` and `agentCtx.remote.`. Each namespace is a traced Cordis child Service registered as `remote.`; the Client assembly mounts contributions through `ctx.remote.$mount()`, consumers inject both `remote` and the namespace Service they call, and the namespace unloads after its last method is withdrawn. When an `@Remote` method has exactly one lookup parameter and a same-named `TypeRTContextMap` uses the same wire identity, the generated scoped signature omits that identity parameter. `@RemoteContext` generates only the scoped invocation interface. ```ts import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -67,13 +67,13 @@ declare const ctx: Context declare const agentCtx: AgentContext declare const agentId: SessionId -await ctx.api.goals.create(agentId, { objective: 'ship it' }) -await agentCtx.goals.create({ objective: 'ship it' }) +await ctx.remote.goals.create(agentId, { objective: 'ship it' }) +await agentCtx.remote.goals.create({ objective: 'ship it' }) ``` -Client applications assemble only `@deepseek-ai/dsh-api-remotes`. That package imports the `/remote` subpaths of selected business packages as runtime values, mounts their contributions on `ctx.api`, and re-exports the declaration merges from the same files. Adding a Host Remote package is an explicit choice by the Client composition owner; business components do not need to load the TypeRT Gateway or the business package's Remote JS separately. +Client applications assemble only `@deepseek-ai/dsh-api-remotes`. That package imports the `/remote` subpaths of selected business packages as runtime values, mounts their contributions through `ctx.remote.$mount()`, and re-exports the declaration merges from the same files. Adding a Host Remote package is an explicit choice by the Client composition owner; business components do not need to load the TypeRT Gateway or the business package's Remote JS separately. -A future TUI can assemble the same React-independent `api-remotes` and `ctx.api` contract, so the Host methods visible to it are likewise limited to the Remote methods selected at generation time. This document does not define or implement the TUI composition. +A future TUI can assemble the same React-independent `api-remotes` and `ctx.remote` contract, so the Host methods visible to it are likewise limited to the Remote methods selected at generation time. This document does not define or implement the TUI composition. ## Component responsibilities @@ -84,11 +84,11 @@ A future TUI can assemble the same React-independent `api-remotes` and `ctx.api` | Host | `@deepseek-ai/dsh-typert-registry` and Loader | Places generated Host descriptors, schemas, and business-package registrations in `ctx.typert`, and holds lookup and Context providers | | Host | `@deepseek-ai/dsh-api-remotes` | Owns the application Agent/Session identity policy and configures the corresponding TypeRT lookups | | Host | `@deepseek-ai/dsh-api-gateway` | Provides `ctx.typertGateway`, claims Remote endpoints, resolves objects or Contexts, invokes live Cordis services, and validates boundaries | -| Client | `@deepseek-ai/dsh-api-gateway/client` | Provides `ctx.api`, mounts generated descriptors as concrete methods, and initiates, validates, and cancels calls through the Connection | +| Client | `@deepseek-ai/dsh-api-gateway/client` | Provides `ctx.remote` and `remote.` child Services, mounts generated descriptors as concrete methods, and initiates, validates, and cancels calls through the Connection | | Client | `@deepseek-ai/dsh-api-remotes/client` | Explicitly selects and mounts the `/remote` contributions allowed by the application and brings the corresponding declaration merges into business code | | Both | `@deepseek-ai/dsh-client-connection` | Provides the RPC carrier, request correlation, trust boundary, cancellation, response envelope, and current `/api` HTTP bridge | -The API Gateway package owns the Host dispatcher and Client API as peer entries, but the two builds never enter the same `ts.Program`. The Host entry does not import the Client Cordis `Context` merge, and the Client entry does not import the Host Gateway service. +The API Gateway package owns the Host dispatcher and Client Remote endpoint as peer entries, but the two builds never enter the same `ts.Program`. The Host entry does not import the Client Cordis `Context` merge, and the Client entry does not import the Host Gateway service. ## Strict generation pipeline @@ -106,13 +106,13 @@ Each contributing business package writes generated files to its own `lib/` dire Business packages expose the Host Loader entry through `./typert` and the Host-for-Client entry through `./remote`. The generator also validates these package exports and published-file lists; it generates artifacts only for explicit contribution packages that provide the corresponding entry. -Parameter names in Remote Client declarations come from wire fields, while parameter and return types reference Client-safe types exported by the original business package. The declaration map resolves the generated property behind `ctx.api.goals.create` back to the Host source method marked with `@Remote`, so editors that support declaration maps can navigate from a Client call to the real implementation instead of stopping at the generated `.d.ts`. +Parameter names in Remote Client declarations come from wire fields, while parameter and return types reference Client-safe types exported by the original business package. The declaration map resolves the generated property behind `ctx.remote.goals.create` back to the Host source method marked with `@Remote`, so editors that support declaration maps can navigate from a Client call to the real implementation instead of stopping at the generated `.d.ts`. Strict analysis requires a Remote to be a public, non-static instance method with a concrete implementation. The method cannot be generic; parameters must be required, named simple identifiers and cannot use destructuring, default values, rest parameters, or optional parameters. TypeRT generates strict schemas for ordinary JSON-representable types; complex objects such as workspace classes must have a unique `TypeRTLookupMap` declaration. Lookup and Context packages are responsible for both static declaration merges and runtime provider registration; if either side is missing, the build or earliest resolvable runtime boundary fails. ## Runtime invocation -Remote and API Proxy currently share the Connection's `/api` route; there is no separate `/api2` server or second Connection. The Client API calls `connection.rpc.call('/api', '/', { args }, signal)`; the current HTTP carrier maps this to `POST /api//`, with a payload containing only a named `args` object. +Remote and API Proxy currently share the Connection's `/api` route; there is no separate `/api2` server or second Connection. The Client Remote calls `connection.rpc.call('/api', '/', { args }, signal)`; the current 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 in the future does not require changes to Remote descriptors or the Client programming interface. @@ -128,7 +128,7 @@ When the Host starts from source through `node --import tsx/esm`, it does not ex The SRC fallback parses simple parameter names from the live function. When a parameter name matches the `parameter` of a registered lookup, such as `agent` or `session`, it uses the lookup's `agentId` or `sessionId` wire field and resolves the object on the Host; other parameters are checked only for cycle-free, JSON-safe data with no special prototype. `@RemoteContext` directly uses the wire field of a registered Host Context provider. SRC does not read TypeScript types, generate Zod schemas, infer optional parameters, or support destructuring, default values, rest parameters, or duplicate parameter names. -SRC solves only dispatch for a Host process running from source. The Client does not discover decorators from the running Host, and the Client API refuses to mount SRC descriptors that lack strict codecs; its types, codecs, and Remote registration values always come from the most recently generated `lib/typert.remote-client.*` artifacts. +SRC solves only dispatch for a Host process running from source. The Client does not discover decorators from the running Host, and the Client Remote refuses to mount SRC descriptors that lack strict codecs; its types, codecs, and Remote registration values always come from the most recently generated `lib/typert.remote-client.*` artifacts. ## Development mode diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index 9d7286b6b8..6fcbb562b2 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -6,7 +6,7 @@ ## 编程模型 -业务 Service 通过 `@Remote` 或 `@RemoteContext` 选择对 Client 开放的方法。未标记的方法不会进入生成的 Client 类型或运行时贡献,也不能通过 `ctx.api` 调用。 +业务 Service 通过 `@Remote` 或 `@RemoteContext` 选择对 Client 开放的方法。未标记的方法不会进入生成的 Client 类型或运行时贡献,也不能通过 `ctx.remote` 调用。 `@Remote` 表示调用根 Host Context 中注册的 Cordis Service。复杂的 Host 对象不能直接跨 wire 传输;业务包必须通过 `TypeRTLookupMap` 声明它与 wire identity 的关联,并在运行时向 `ctx.typert.lookups` 注册默认解析提供方。例如 `Agent` 参数在 Host 签名中名为 `agent`,生成的 wire 字段为 `agentId`,Gateway 在调用业务方法前将 id 解析为 Host 对象。Host 组合可以用 `ctx.typert.lookups.configure()` 覆盖某个 lookup key 的解析策略,而不改变业务包拥有的参数名、wire 字段或规范类型 symbol。 @@ -55,7 +55,7 @@ export class GoalService extends GatewayService { Remote 方法可以同步返回或返回 Promise。若需要协作式取消,Host 签名的最后一个参数必须是全局类型的 `signal: AbortSignal`;它记录在描述符中而不是进入 `args`,Client 生成的方法则接受最后一个可选的 `AbortSignal`。 -Client 使用普通对象上的具体函数,不使用 JavaScript Proxy。直接 Remote 出现在 `ctx.api.`;当一个 `@Remote` 方法恰好有一个 lookup 参数、且同名 `TypeRTContextMap` 使用相同 wire identity 时,生成器还会把去掉该 identity 参数后的方法投影到对应作用域 Context。`@RemoteContext` 只生成作用域调用界面。 +Client 使用普通对象上的具体函数,不使用 JavaScript Proxy。直接调用与作用域调用分别出现在 `ctx.remote.` 和 `agentCtx.remote.`。每个 namespace 都是注册为 `remote.` 的可追踪 Cordis 子 Service;Client assembly 通过 `ctx.remote.$mount()` 挂载贡献,消费方同时注入 `remote` 与所调用的 namespace Service,最后一个方法撤回后该 namespace 随即卸载。当一个 `@Remote` 方法恰好有一个 lookup 参数、且同名 `TypeRTContextMap` 使用相同 wire identity 时,生成的作用域签名会省略该 identity 参数。`@RemoteContext` 只生成作用域调用界面。 ```ts import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -67,13 +67,13 @@ declare const ctx: Context declare const agentCtx: AgentContext declare const agentId: SessionId -await ctx.api.goals.create(agentId, { objective: 'ship it' }) -await agentCtx.goals.create({ objective: 'ship it' }) +await ctx.remote.goals.create(agentId, { objective: 'ship it' }) +await agentCtx.remote.goals.create({ objective: 'ship it' }) ``` -Client 应用只装配 `@deepseek-ai/dsh-api-remotes`。该包以运行时值导入被选业务包的 `/remote` 子路径,并向 `ctx.api` 挂载贡献,同时重新导出相同文件中的声明合并。增加一个 Host Remote 包是 Client 组合所有者的显式选择;业务组件不需要分别加载 TypeRT Gateway 或业务包的 Remote JS。 +Client 应用只装配 `@deepseek-ai/dsh-api-remotes`。该包以运行时值导入被选业务包的 `/remote` 子路径,通过 `ctx.remote.$mount()` 挂载贡献,同时重新导出相同文件中的声明合并。增加一个 Host Remote 包是 Client 组合所有者的显式选择;业务组件不需要分别加载 TypeRT Gateway 或业务包的 Remote JS。 -未来的 TUI 可以装配同一个不依赖 React 的 `api-remotes` 与 `ctx.api` 契约,因此它能看到的 Host 方法同样只限于生成时选择的 Remote 方法。本文不定义或实现 TUI 组合。 +未来的 TUI 可以装配同一个不依赖 React 的 `api-remotes` 与 `ctx.remote` 契约,因此它能看到的 Host 方法同样只限于生成时选择的 Remote 方法。本文不定义或实现 TUI 组合。 ## 组件职责 @@ -84,11 +84,11 @@ Client 应用只装配 `@deepseek-ai/dsh-api-remotes`。该包以运行时值导 | Host | `@deepseek-ai/dsh-typert-registry` 与 Loader | 把生成的 Host 描述符、schema 及业务包注册项放入 `ctx.typert`,并持有 lookup 与 Context 提供方 | | Host | `@deepseek-ai/dsh-api-remotes` | 负责应用的 Agent/Session 身份策略,并配置对应的 TypeRT lookup | | Host | `@deepseek-ai/dsh-api-gateway` | 提供 `ctx.typertGateway`,认领 Remote endpoint,解析对象或 Context,调用实时 Cordis Service 并校验边界 | -| Client | `@deepseek-ai/dsh-api-gateway/client` | 提供 `ctx.api`,把生成的描述符挂成具体方法,并通过 Connection 发起、校验和取消调用 | +| Client | `@deepseek-ai/dsh-api-gateway/client` | 提供 `ctx.remote` 与 `remote.` 子 Service,把生成的描述符挂成具体方法,并通过 Connection 发起、校验和取消调用 | | Client | `@deepseek-ai/dsh-api-remotes/client` | 显式选择并挂载本应用允许使用的 `/remote` 贡献,向业务代码带入对应的声明合并 | | 双侧 | `@deepseek-ai/dsh-client-connection` | 提供 RPC carrier、请求关联、信任边界、取消、响应 envelope 与当前 `/api` HTTP bridge | -API Gateway 包同时拥有 Host dispatcher 与 Client API 两个对等入口,但两侧构建不会进入同一个 `ts.Program`。Host 入口不导入 Client 的 Cordis `Context` 合并,Client 入口也不导入 Host Gateway 服务。 +API Gateway 包同时拥有 Host dispatcher 与 Client Remote endpoint 两个对等入口,但两侧构建不会进入同一个 `ts.Program`。Host 入口不导入 Client 的 Cordis `Context` 合并,Client 入口也不导入 Host Gateway 服务。 ## 严格生成链路 @@ -106,13 +106,13 @@ API Gateway 包同时拥有 Host dispatcher 与 Client API 两个对等入口, 业务包通过 `./typert` 暴露 Host Loader 入口,通过 `./remote` 暴露 Host-for-Client 入口。生成器同时校验这些 package export 及发布文件清单;只有具备相应入口的显式贡献包才会生成产物。 -Remote Client 声明中的参数名来自 wire 字段,参数和返回类型则引用原业务包导出的 Client-safe 类型。声明 map 把 `ctx.api.goals.create` 最终解析到的生成属性映射到带 `@Remote` 的 Host 源方法,因此支持 declaration-map 的编辑器可以从 Client 调用跳到真实实现,而不是停在生成的 `.d.ts`。 +Remote Client 声明中的参数名来自 wire 字段,参数和返回类型则引用原业务包导出的 Client-safe 类型。声明 map 把 `ctx.remote.goals.create` 最终解析到的生成属性映射到带 `@Remote` 的 Host 源方法,因此支持 declaration-map 的编辑器可以从 Client 调用跳到真实实现,而不是停在生成的 `.d.ts`。 严格分析要求 Remote 是公开、非静态、有具体实现的实例方法。方法不能是泛型;参数必须是具名且必填的简单标识符,不能使用解构、默认值、rest 或可选参数。可 JSON 表示的普通类型由 TypeRT 生成严格 schema;工作区 class 等复杂对象必须具有唯一的 `TypeRTLookupMap` 声明。lookup 与 Context 包同时负责静态声明合并和运行时提供方注册,缺少任一侧都会在构建或最早可解析的运行时边界报错。 ## 运行时调用 -当前 Remote 与 API Proxy 共用 Connection 的 `/api` 路由,不存在独立 `/api2` server 或第二套 Connection。Client API 调用 `connection.rpc.call('/api', '/', { args }, signal)`;当前 HTTP carrier 对应 `POST /api//`,payload 只包含一个具名 `args` 对象。 +当前 Remote 与 API Proxy 共用 Connection 的 `/api` 路由,不存在独立 `/api2` server 或第二套 Connection。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 和 request cancellation,Gateway 只拥有 Remote 数据协议和业务分发。未来替换 Connection carrier 不要求改变 Remote 描述符或 Client 编程界面。 @@ -128,7 +128,7 @@ Host 通过 `node --import tsx/esm` 从源码启动时不会执行 TypeRT 编译 SRC 回退从运行中函数解析简单参数名。参数名与某个已注册 lookup 的 `parameter` 相同,例如 `agent` 或 `session`,就使用其 `agentId` 或 `sessionId` wire 字段并在 Host 解析对象;其他参数只检查值是否为无循环、无特殊 prototype 的 JSON-safe 数据。`@RemoteContext` 直接使用已注册 Host Context provider 的 wire 字段。SRC 不读取 TypeScript 类型,不生成 Zod schema,不推断可选参数,也不支持解构、默认值、rest 或重复参数名。 -SRC 只解决 Host 源码进程的分发问题。Client 不会从运行中的 Host 发现 decorator,Client API 也拒绝挂载缺少严格 codec 的 SRC 描述符;其类型、codec 和 Remote 注册值始终来自最近一次生成的 `lib/typert.remote-client.*`。 +SRC 只解决 Host 源码进程的分发问题。Client 不会从运行中的 Host 发现 decorator,Client Remote 也拒绝挂载缺少严格 codec 的 SRC 描述符;其类型、codec 和 Remote 注册值始终来自最近一次生成的 `lib/typert.remote-client.*`。 ## 开发模式 diff --git a/docs/core-data-structures/typert.i18n.yaml b/docs/core-data-structures/typert.i18n.yaml index a6e1eb5415..75b7837687 100644 --- a/docs/core-data-structures/typert.i18n.yaml +++ b/docs/core-data-structures/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/core-data-structures/typert.md -typert.md: a61ed8587833e03fd5c1246311e62a6ffaeb3bd0 -typert.zh.md: 18c24018f4abd644cf35185c2bd06b6980195481 +typert.md: c70e50e2fea8455eb75dfdf8c309f659ab9cb2f9 +typert.zh.md: 2cd1636d4cc8dbcfa009073b4a8e1dcc8d5897e4 diff --git a/docs/core-data-structures/typert.md b/docs/core-data-structures/typert.md index a61ed85878..c70e50e2fe 100644 --- a/docs/core-data-structures/typert.md +++ b/docs/core-data-structures/typert.md @@ -126,10 +126,10 @@ interface TypeRTService { } ``` -Generated consumer declarations merge direct namespaces into the map inherited by `TypeRTClientApi`. +Generated consumer declarations merge direct namespaces into the map inherited by `TypeRTClientRemote`. ```ts type-equiv -/** Merge-extensible direct namespace surface generated for Client API services. */ +/** Merge-extensible direct namespace surface generated for Client Remote services. */ interface TypeRTRemoteNamespaceMap {} ``` @@ -186,18 +186,18 @@ interface TypertGateway { } ``` -## Consumer API +## Consumer Remote -`ctx.api` exposes only namespaces contributed by imported `/remote` artifacts. Mounting installs the generated descriptors and concrete root/scoped methods as one fiber-owned operation; no JavaScript Proxy or Host Service type enters the consumer. +`ctx.remote` exposes only namespaces contributed by imported `/remote` artifacts. `$mount()` installs generated descriptors and concrete methods as one fiber-owned operation. Each namespace is a traced `remote.` Cordis child Service whose lifetime spans its mounted methods; no JavaScript Proxy or Host business Service type enters the consumer. ```ts type-equiv -/** Client API capability implemented by the Gateway and consumed by Remote assemblies. */ -interface TypeRTClientApi extends TypeRTRemoteNamespaceMap { +/** Client Remote capability implemented by the Gateway and consumed by Remote assemblies. */ +interface TypeRTClientRemote extends TypeRTRemoteNamespaceMap { /** * Mount one generated Host-for-Client contribution in the caller's fiber. * @param contribution - explicitly selected Remote package artifact. - * @returns disposer withdrawing descriptors and concrete methods together. + * @returns disposer after namespace services and concrete methods are ready. */ - mount(contribution: TypeRTRemoteContribution): TypeRTDisposer + $mount(contribution: TypeRTRemoteContribution): Promise } ``` diff --git a/docs/core-data-structures/typert.zh.md b/docs/core-data-structures/typert.zh.md index 18c24018f4..2cd1636d4c 100644 --- a/docs/core-data-structures/typert.zh.md +++ b/docs/core-data-structures/typert.zh.md @@ -126,10 +126,10 @@ interface TypeRTService { } ``` -生成的消费方声明会把 direct namespace 合并到 `TypeRTClientApi` 继承的 map 中。 +生成的消费方声明会把 direct namespace 合并到 `TypeRTClientRemote` 继承的 map 中。 ```ts type-equiv -/** Merge-extensible direct namespace surface generated for Client API services. */ +/** Merge-extensible direct namespace surface generated for Client Remote services. */ interface TypeRTRemoteNamespaceMap {} ``` @@ -186,18 +186,18 @@ interface TypertGateway { } ``` -## 消费方 API +## 消费方 Remote -`ctx.api` 只暴露由已导入 `/remote` 产物贡献的 namespace。挂载会把生成的 descriptor 与具体的 root/scoped 方法作为一项由 fiber 持有的操作统一注册;JavaScript Proxy 与 Host 服务类型都不会进入消费方。 +`ctx.remote` 只暴露由已导入 `/remote` 产物贡献的 namespace。`$mount()` 会把生成的 descriptor 与具体方法作为一项由 fiber 持有的操作统一注册。每个 namespace 都是可追踪的 `remote.` Cordis 子 Service,其生命周期覆盖已挂载的方法;JavaScript Proxy 与 Host 业务 Service 类型都不会进入消费方。 ```ts type-equiv -/** Client API capability implemented by the Gateway and consumed by Remote assemblies. */ -interface TypeRTClientApi extends TypeRTRemoteNamespaceMap { +/** Client Remote capability implemented by the Gateway and consumed by Remote assemblies. */ +interface TypeRTClientRemote extends TypeRTRemoteNamespaceMap { /** * Mount one generated Host-for-Client contribution in the caller's fiber. * @param contribution - explicitly selected Remote package artifact. - * @returns disposer withdrawing descriptors and concrete methods together. + * @returns disposer after namespace services and concrete methods are ready. */ - mount(contribution: TypeRTRemoteContribution): TypeRTDisposer + $mount(contribution: TypeRTRemoteContribution): Promise } ``` diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index b0809af72e..933f204fa0 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: f832956c4c7cbde96613a69db6c636a2246786a7 -development.zh.md: 3ae70e7135ad5faee0e37d99f55cdb41373aab2c +development.md: 37bc88c7c1cfedfbe1a93e08a4cbde833ac32372 +development.zh.md: a738e53cb3434d7930aa82107782a4c22aea1470 diff --git a/docs/development.md b/docs/development.md index f832956c4c..37bc88c7c1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -62,7 +62,7 @@ Host and client stay two aggregate programs because both sides declaration-merge Static analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md). -Business services declare callable methods on the Host with `@Remote` or `@RemoteContext`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions and mounts concrete methods on `ctx.api` or the corresponding scoped Context. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order. +Business services declare callable methods on the Host with `@Remote` or `@RemoteContext`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions under `ctx.remote` and scoped `agentCtx.remote` namespaces. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order. If a relevant local check consumes built package output, build once first: diff --git a/docs/development.zh.md b/docs/development.zh.md index 3ae70e7135..a738e53cb3 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -62,7 +62,7 @@ host 与 client 保持两个聚合 program,是因为两侧在相同键下以 静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。 -业务 Service 在 Host 使用 `@Remote` 或 `@RemoteContext` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并将具体方法挂载到 `ctx.api` 或对应的作用域 Context。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。 +业务 Service 在 Host 使用 `@Remote` 或 `@RemoteContext` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并挂到 `ctx.remote` 与作用域 `agentCtx.remote` namespace。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。 如果相关的本地检查需要使用构建后的包产物,请先构建一次: diff --git a/packages/api/README.i18n.yaml b/packages/api/README.i18n.yaml index 855eeb8eaa..6a834cdf4d 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: 0dcded5922fea1ea6676315029ba0eadd74dd3df -README.zh.md: 1b9bb9133a955d0cbef0ca91728aab1545831d94 +README.md: 7c75e8012459266e0ce09c97416d140e5ac777e1 +README.zh.md: 87bd15fc4e5ad23ef785f7c9ee805a4aa1a35e46 diff --git a/packages/api/README.md b/packages/api/README.md index 0dcded5922..7c75e80124 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -6,10 +6,10 @@ The application-facing Remote stack. `remotes` owns BFF policy and the selected | Package | Role | ctx key | |---|---|---| -| [`remotes/`](remotes/README.md) | Host Agent/Session lookup policy and Client Remote contribution assembly | no service; configures `ctx.typert` and consumes `ctx.api` | -| [`gateway/`](gateway/README.md) | Host TypeRT dispatcher and Client API endpoint | `ctx.typertGateway` / `ctx.api` | +| [`remotes/`](remotes/README.md) | Host Agent/Session lookup policy and Client Remote contribution assembly | no service; configures `ctx.typert` and consumes `ctx.remote` | +| [`gateway/`](gateway/README.md) | Host TypeRT dispatcher and Client Remote endpoint | `ctx.typertGateway` / `ctx.remote` | -The runtime dependency direction is `remotes → gateway → connection → webserver`: the BFF consumes the shared `TypeRTClientApi` contract, Gateway delegates transport to Connection, and Connection mounts on the HTTP server. Cordis service injection and Client module metadata preserve this order without importing the concrete Gateway from the Remotes Client entry. +The runtime dependency direction is `remotes → gateway → connection → webserver`: the BFF consumes the shared `TypeRTClientRemote` contract, Gateway delegates transport to Connection, and Connection mounts on the HTTP server. Cordis service injection and Client module metadata preserve this order without importing the concrete Gateway from the Remotes Client entry. ## Known Limitations and Deferred Work diff --git a/packages/api/README.zh.md b/packages/api/README.zh.md index 1b9bb9133a..87bd15fc4e 100644 --- a/packages/api/README.zh.md +++ b/packages/api/README.zh.md @@ -6,10 +6,10 @@ | 包 | 职责 | ctx key | |---|---|---| -| [`remotes/`](remotes/README.md) | Host Agent/Session lookup 策略与 Client Remote contribution 装配 | 无服务;配置 `ctx.typert` 并消费 `ctx.api` | -| [`gateway/`](gateway/README.md) | Host TypeRT 分发器与 Client API endpoint | `ctx.typertGateway` / `ctx.api` | +| [`remotes/`](remotes/README.md) | Host Agent/Session lookup 策略与 Client Remote contribution 装配 | 无服务;配置 `ctx.typert` 并消费 `ctx.remote` | +| [`gateway/`](gateway/README.md) | Host TypeRT 分发器与 Client Remote endpoint | `ctx.typertGateway` / `ctx.remote` | -运行时依赖方向为 `remotes → gateway → connection → webserver`:BFF 消费共享的 `TypeRTClientApi` 契约,Gateway 把传输交给 Connection,Connection 再挂载到 HTTP server。Cordis 服务注入与 Client 模块元数据在不让 Remotes Client 入口导入具体 Gateway 实现的前提下维持该顺序。 +运行时依赖方向为 `remotes → gateway → connection → webserver`:BFF 消费共享的 `TypeRTClientRemote` 契约,Gateway 把传输交给 Connection,Connection 再挂载到 HTTP server。Cordis 服务注入与 Client 模块元数据在不让 Remotes Client 入口导入具体 Gateway 实现的前提下维持该顺序。 ## 已知限制与延期工作 diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index 41bbb0621f..3a9a0ba50d 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: 9e3d4d89788bbc6edebfc0c0127999fed3ed9261 -README.zh.md: 9bbd46c71185a2fbf8da163565d6c19141c079ca +README.md: e37359db71c1388667e9e61f538354711e90c0c1 +README.zh.md: 2054febb9a5423297c32b029b40a035062250aab diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index 9e3d4d8978..e37359db71 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Two-sided TypeRT RPC endpoint for Host and Client Cordis environments. The Host entry provides `ctx.typertGateway`, while `@deepseek-ai/dsh-api-gateway/client` provides `ctx.api`; both consume the same generated `InvocationDescriptor` contract and leave business selection to API Remotes and transport, request correlation, trust, and response envelopes to Connection. +Two-sided TypeRT RPC endpoint for Host and Client Cordis environments. The Host entry provides `ctx.typertGateway`, while `@deepseek-ai/dsh-api-gateway/client` provides `ctx.remote`; both consume the same generated `InvocationDescriptor` contract and leave business selection to API Remotes and transport, request correlation, trust, and response envelopes to Connection. ## Host service: `TypertGatewayService` (ctx key: `typertGateway`) @@ -14,13 +14,13 @@ 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. -## Client service: `ClientApi` (ctx key: `api`) +## Client service: `ClientRemote` (ctx key: `remote`) -`ctx.api.mount()` validates and registers a generated Host-for-Client contribution, then installs concrete direct and scoped methods for the calling Cordis fiber. Duplicate endpoints, namespace collisions, and descriptors without strict generated codecs fail before methods become callable. +`ctx.remote.$mount()` validates and registers a generated Host-for-Client contribution, then installs concrete direct and scoped methods for the calling Cordis fiber. Each namespace is a traced `remote.` child Service and unloads after its last method is withdrawn. Duplicate endpoints, namespace collisions, and descriptors without strict generated codecs fail before methods become callable. Each call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api', endpoint, ...)`. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before calling Connection. The returned value is validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls, and makes retained method handles reject. -Generated declaration merges provide the TypeScript API through the shared `TypeRTClientApi` 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. +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. ## Model Experience diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index 9bbd46c711..2054febb9a 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -为 Host 与 Client 两侧的 Cordis 环境提供 TypeRT RPC endpoint。Host 入口提供 `ctx.typertGateway`,`@deepseek-ai/dsh-api-gateway/client` 则提供 `ctx.api`;两者使用同一份生成的 `InvocationDescriptor` 契约,并将业务选择交给 API Remotes,将传输、请求关联、信任和响应封装交给 Connection。 +为 Host 与 Client 两侧的 Cordis 环境提供 TypeRT RPC endpoint。Host 入口提供 `ctx.typertGateway`,`@deepseek-ai/dsh-api-gateway/client` 则提供 `ctx.remote`;两者使用同一份生成的 `InvocationDescriptor` 契约,并将业务选择交给 API Remotes,将传输、请求关联、信任和响应封装交给 Connection。 ## Host 服务:`TypertGatewayService`(ctx key:`typertGateway`) @@ -14,13 +14,13 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandle 支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。 -## Client 服务:`ClientApi`(ctx key:`api`) +## Client 服务:`ClientRemote`(ctx key:`remote`) -`ctx.api.mount()` 会校验并注册生成的 Host-for-Client 贡献项,然后为发起调用的 Cordis fiber 安装具体的直接方法和作用域方法。重复端点、命名空间冲突,以及缺少生成的严格编解码器的描述符,都会在方法可调用前报错。 +`ctx.remote.$mount()` 会校验并注册生成的 Host-for-Client 贡献项,然后为发起调用的 Cordis fiber 安装具体的直接方法和作用域方法。每个 namespace 都是可追踪的 `remote.` 子 Service,并在最后一个方法撤回后卸载。重复端点、命名空间冲突,以及缺少生成的严格编解码器的描述符,都会在方法可调用前报错。 每次调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用 Connection 前将它与贡献项的挂载生命周期合并。返回值经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用,并使外部仍持有的方法句柄在调用时返回拒绝。 -生成的声明合并通过共享的 `TypeRTClientApi` 契约提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。 +生成的声明合并通过共享的 `TypeRTClientRemote` 契约提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。 ## 模型体验 diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index a9343823ff..d0429339c8 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -1,37 +1,25 @@ /** * Client projection of generated TypeRT Remote descriptors. Contributions - * install concrete namespace methods; no JavaScript Proxy participates in - * lookup, invocation, or type exposure. + * install traced `remote.` services; no JavaScript Proxy + * participates in method lookup, invocation, or type exposure. */ -import { Service, symbols } from 'cordis' +import { Service } from 'cordis' import type { Context } from 'cordis' import type { ConnectionHandle, RpcError } from '@deepseek-ai/dsh-client-connection/client' import type { InvocationDescriptor, - TypeRTClientApi, + TypeRTClientRemote, TypeRTCodec, TypeRTDisposer, TypeRTRemoteContribution, } from '@deepseek-ai/dsh-type-meta' -type RemoteMethod = (...args: unknown[]) => Promise - interface MountToken { active: boolean readonly abort: AbortController } -interface DirectNamespaceRecord { - readonly value: Record - readonly tokens: Map -} - -interface ScopedNamespaceRecord { - readonly service: ScopedRemoteNamespace - readonly tokens: Map -} - interface ScopedProjection { readonly context: string readonly wire: string @@ -39,13 +27,36 @@ interface ScopedProjection { readonly parameterIndex?: number } -/** Typed API service augmented by generated direct Remote namespaces. */ -export type ClientApi = TypeRTClientApi +interface DirectMethod { + readonly descriptor: InvocationDescriptor + readonly token: MountToken +} + +interface ScopedMethod extends DirectMethod { + readonly projection: ScopedProjection +} + +interface RemoteMethodRecord { + direct?: DirectMethod + scoped?: ScopedMethod +} + +interface BoundContextIdentity { + readonly value: unknown +} + +interface RemoteNamespaceHandle { + readonly service: RemoteNamespaceService + readonly dispose: TypeRTDisposer +} + +/** Typed Remote service augmented by generated direct namespaces. */ +export type ClientRemote = TypeRTClientRemote declare module 'cordis' { interface Context { - /** Generated direct Remote namespaces selected by the Client assembly. */ - api: ClientApi + /** Generated Remote namespaces selected by the Client assembly. */ + remote: ClientRemote } } @@ -53,48 +64,56 @@ declare module 'cordis' { export const inject = ['typert', 'connection'] /** - * Install the typed Client API service. + * Install the typed Client Remote service. * @param ctx - Client Cordis root. */ export function apply(ctx: Context): void { - new ClientApiService(ctx) + new ClientRemoteService(ctx) } -class ClientApiService extends Service implements TypeRTClientApi { +class ClientRemoteService extends Service implements TypeRTClientRemote { private readonly ownerCtx: Context - private readonly direct = new Map() - private readonly scoped = new Map() + private readonly namespaces = new Map() + private mutations = Promise.resolve() constructor(ctx: Context) { - super(ctx, 'api') + super(ctx, 'remote') this.ownerCtx = ctx } - mount(contribution: TypeRTRemoteContribution): ReturnType { - this.validateContribution(contribution) + async $mount(contribution: TypeRTRemoteContribution): ReturnType { const callerCtx = this.ctx + const owned = callerCtx.effect(async () => { + const dispose = await this.enqueue(() => this.mountContribution(callerCtx, contribution)) + return () => this.enqueue(dispose) + }, `api-gateway.client.$mount(${JSON.stringify(contribution.package)})`) + await owned + return async () => { await owned() } + } + + private enqueue(operation: () => T | Promise): Promise { + const result = this.mutations.then(operation, operation) + this.mutations = result.then(() => undefined, () => undefined) + return result + } + + private async mountContribution( + callerCtx: Context, + contribution: TypeRTRemoteContribution, + ): Promise { + this.validateContribution(contribution) const disposeRemote = callerCtx.typert.remotes.register(contribution) - let disposeMethods: () => void | Promise + const installed: TypeRTDisposer[] = [] try { - disposeMethods = callerCtx.effect(() => { - const installed: Array<() => void> = [] - try { - for (const descriptor of contribution.descriptors) installed.push(this.install(descriptor)) - } catch (error) { - for (const dispose of installed.reverse()) dispose() - throw error - } - return () => { - for (const dispose of installed.reverse()) dispose() - } - }, `api-gateway.client.mount(${JSON.stringify(contribution.package)})`) + for (const descriptor of contribution.descriptors) installed.push(await this.install(descriptor)) } catch (error) { - /* v8 ignore next -- rollback disposal only rejects if Cordis teardown itself fails while handling the installation error. */ - Promise.resolve(disposeRemote()).catch(() => {}) + for (const dispose of installed.reverse()) await dispose() + await disposeRemote() throw error } return async () => { - await Promise.all([disposeMethods(), disposeRemote()]) + for (const dispose of installed.reverse()) await dispose() + await disposeRemote() } } @@ -112,10 +131,8 @@ class ClientApiService extends Service implements TypeRTClientApi { } methods.add(descriptor.method) table.set(descriptor.namespace, methods) - const live = kind === 'direct' - ? this.direct.get(descriptor.namespace)?.tokens - : this.scoped.get(descriptor.namespace)?.tokens - if (live?.has(descriptor.method) === true) { + const namespace = this.namespaces.get(descriptor.namespace)?.service + if (namespace?.has(kind, descriptor.method) === true) { throw new Error(`client api: ${kind} method ${endpointOf(descriptor)} is already mounted`) } } @@ -124,118 +141,151 @@ class ClientApiService extends Service implements TypeRTClientApi { if (descriptor.invocation.kind === 'direct') add(direct, descriptor, 'direct') if (scopedProjection(descriptor) !== undefined) add(scoped, descriptor, 'scoped') } - for (const namespace of direct.keys()) { - if (!this.direct.has(namespace) && namespace in this) { - throw new Error(`client api: namespace ${JSON.stringify(namespace)} conflicts with the API service`) - } - } - for (const [namespace, methods] of scoped) { - const record = this.scoped.get(namespace) - if (record !== undefined) { - for (const method of methods) record.service.assertMethodAvailable(method) - } else { - for (const method of methods) ScopedRemoteNamespace.assertMethodAvailable(namespace, method) - const property = this.ownerCtx.reflect.props[namespace] - if (property?.type === 'accessor' || this.ownerCtx.get(namespace) !== undefined) { - throw new Error(`client api: scoped namespace ${JSON.stringify(namespace)} conflicts with an existing Context property`) + const namespaces = new Set([...direct.keys(), ...scoped.keys()]) + for (const namespace of namespaces) { + const service = this.namespaces.get(namespace)?.service + if (service === undefined) { + if (namespace in this) { + throw new Error(`client api: namespace ${JSON.stringify(namespace)} conflicts with the Remote service`) } + const serviceKey = remoteServiceKey(namespace) + const property = this.ownerCtx.reflect.props[serviceKey] + if (property?.type === 'accessor' || this.ownerCtx.get(serviceKey) !== undefined) { + throw new Error(`client api: namespace ${JSON.stringify(namespace)} conflicts with an existing Remote namespace`) + } + } + for (const method of new Set([...(direct.get(namespace) ?? []), ...(scoped.get(namespace) ?? [])])) { + if (service === undefined) RemoteNamespaceService.assertMethodAvailable(namespace, method) + else service.assertMethodAvailable(method) } } } - private install(descriptor: InvocationDescriptor): () => void { + private async install(descriptor: InvocationDescriptor): Promise { const token: MountToken = { active: true, abort: new AbortController() } - const installed: (() => void)[] = [] + const installed: TypeRTDisposer[] = [] try { if (descriptor.invocation.kind === 'direct') { - installed.push(this.installDirect(descriptor, token)) + installed.push(await this.installDirect(descriptor, token)) } const projection = scopedProjection(descriptor) - if (projection !== undefined) installed.push(this.installScoped(descriptor, projection, token)) + if (projection !== undefined) installed.push(await this.installScoped(descriptor, projection, token)) } catch (error) { token.active = false - for (const dispose of installed.reverse()) dispose() token.abort.abort() + for (const dispose of installed.reverse()) await dispose() throw error } - return () => { + return async () => { /* v8 ignore next -- Cordis effect disposers are idempotent and invoke this cleanup at most once. */ if (!token.active) return token.active = false - for (const dispose of installed.reverse()) dispose() token.abort.abort() + for (const dispose of installed.reverse()) await dispose() } } - private installDirect(descriptor: InvocationDescriptor, token: MountToken): () => void { - let namespace = this.direct.get(descriptor.namespace) - const fresh = namespace === undefined - if (namespace === undefined) { - namespace = { value: Object.create(null) as Record, tokens: new Map() } - Object.defineProperty(this, descriptor.namespace, { - configurable: true, - enumerable: true, - value: namespace.value, - }) - } + private async installDirect(descriptor: InvocationDescriptor, token: MountToken): Promise { + const namespace = await this.namespace(descriptor.namespace) try { - Object.defineProperty(namespace.value, descriptor.method, { - configurable: true, - enumerable: true, - value: (...args: unknown[]) => this.invoke(descriptor, undefined, token, this.ownerCtx, args), - }) + namespace.service.installDirect(descriptor, token) } catch (error) { - if (fresh) Reflect.deleteProperty(this, descriptor.namespace) + await this.disposeNamespace(descriptor.namespace, namespace) throw error } - if (fresh) this.direct.set(descriptor.namespace, namespace) - namespace.tokens.set(descriptor.method, token) - return () => { - /* v8 ignore next -- duplicate live methods are rejected before installation, so no newer token can replace this one. */ - if (namespace.tokens.get(descriptor.method) !== token) return - Reflect.deleteProperty(namespace.value, descriptor.method) - namespace.tokens.delete(descriptor.method) - if (namespace.tokens.size !== 0) return - this.direct.delete(descriptor.namespace) - Reflect.deleteProperty(this, descriptor.namespace) + return async () => { + if (!namespace.service.remove('direct', descriptor.method, token)) return + await this.disposeNamespace(descriptor.namespace, namespace) } } - private installScoped( + private async installScoped( descriptor: InvocationDescriptor, projection: ScopedProjection, token: MountToken, - ): () => void { - let namespace = this.scoped.get(descriptor.namespace) - if (namespace === undefined) { - const service = new ScopedRemoteNamespace( - this.ownerCtx, - descriptor.namespace, - (current, currentProjection, currentToken, caller, args) => - this.invoke(current, currentProjection, currentToken, caller, args), - ) - service.install(descriptor, projection, token) - namespace = { service, tokens: new Map() } - this.scoped.set(descriptor.namespace, namespace) - } else { - namespace.service.install(descriptor, projection, token) + ): Promise { + const namespace = await this.namespace(descriptor.namespace) + try { + namespace.service.installScoped(descriptor, projection, token) + } catch (error) { + await this.disposeNamespace(descriptor.namespace, namespace) + throw error } - namespace.tokens.set(descriptor.method, token) - return () => { - /* v8 ignore next -- duplicate live methods are rejected before installation, so no newer token can replace this one. */ - if (namespace.tokens.get(descriptor.method) !== token) return - namespace.service.remove(descriptor.method) - namespace.tokens.delete(descriptor.method) - if (namespace.tokens.size === 0) this.scoped.delete(descriptor.namespace) + return async () => { + if (!namespace.service.remove('scoped', descriptor.method, token)) return + await this.disposeNamespace(descriptor.namespace, namespace) } } + private async namespace(name: string): Promise { + let namespace = this.namespaces.get(name) + if (namespace !== undefined) return namespace + let service: RemoteNamespaceService | undefined + const fiber = this.ownerCtx.plugin({ + name: remoteServiceKey(name), + apply: (ctx: Context) => { + service = new RemoteNamespaceService( + ctx, + name, + (direct, scoped, caller, args) => this.invokeMethod(direct, scoped, caller, args), + ) + }, + }) + try { + await fiber + } catch (error) { + await fiber.dispose() + throw error + } + /* v8 ignore next -- a settled namespace fiber synchronously constructs its Service. */ + if (service === undefined) throw new Error(`client api: namespace ${JSON.stringify(name)} did not start`) + namespace = { service, dispose: fiber.dispose } + this.namespaces.set(name, namespace) + return namespace + } + + private async disposeNamespace(name: string, namespace: RemoteNamespaceHandle): Promise { + if (!namespace.service.empty || this.namespaces.get(name) !== namespace) return + this.namespaces.delete(name) + await namespace.dispose() + } + + private invokeMethod( + direct: DirectMethod | undefined, + scoped: ScopedMethod | undefined, + callerCtx: Context, + values: readonly unknown[], + ): Promise { + if (scoped !== undefined) { + const binder = this.ownerCtx.typert.contexts.getClient(scoped.projection.context) + const identity = binder?.identity(callerCtx) + if (identity !== undefined) { + return this.invoke( + scoped.descriptor, + scoped.projection, + scoped.token, + callerCtx, + values, + { value: identity }, + ) + } + } + if (direct !== undefined) { + return this.invoke(direct.descriptor, undefined, direct.token, callerCtx, values) + } + if (scoped !== undefined) { + return this.invoke(scoped.descriptor, scoped.projection, scoped.token, callerCtx, values) + } + throw new Error('client api: Remote method is no longer mounted') + } + private async invoke( descriptor: InvocationDescriptor, projection: ScopedProjection | undefined, token: MountToken, callerCtx: Context, values: readonly unknown[], + boundIdentity?: BoundContextIdentity, ): Promise { const endpoint = endpointOf(descriptor) if (!token.active) throw new Error(`client api: Remote method ${endpoint} is no longer mounted`) @@ -251,11 +301,15 @@ class ClientApiService extends Service implements TypeRTClientApi { } const args = Object.create(null) as Record if (projection !== undefined) { - const binder = this.ownerCtx.typert.contexts.getClient(projection.context) - if (binder === undefined) { + const binder = boundIdentity === undefined + ? this.ownerCtx.typert.contexts.getClient(projection.context) + : undefined + if (boundIdentity === undefined && binder === undefined) { throw new Error(`client api: ${endpoint} has no Client Context binder for ${JSON.stringify(projection.context)}`) } - const identity = binder.identity(callerCtx) + const identity = boundIdentity === undefined + ? binder?.identity(callerCtx) + : boundIdentity.value if (identity === undefined) { throw new Error(`client api: ${endpoint} requires a ${JSON.stringify(projection.context)} Context`) } @@ -281,23 +335,19 @@ class ClientApiService extends Service implements TypeRTClientApi { } type InvokeRemote = ( - descriptor: InvocationDescriptor, - projection: ScopedProjection, - token: MountToken, + direct: DirectMethod | undefined, + scoped: ScopedMethod | undefined, callerCtx: Context, args: readonly unknown[], ) => Promise -class ScopedRemoteNamespace { - private readonly ctx: Context - private readonly ownerCtx: Context - private readonly methods = new Set() - private disposeService: TypeRTDisposer | undefined - readonly name: string +class RemoteNamespaceService extends Service { + private readonly methods = new Map() + private readonly namespace: string static assertMethodAvailable(namespace: string, method: string): void { - if (SCOPED_NAMESPACE_FIELDS.has(method) || method in ScopedRemoteNamespace.prototype) { - throw new Error(`client api: scoped method ${JSON.stringify(`${namespace}/${method}`)} conflicts with its namespace service`) + if (REMOTE_NAMESPACE_FIELDS.has(method) || method in RemoteNamespaceService.prototype) { + throw new Error(`client api: method ${JSON.stringify(`${namespace}/${method}`)} conflicts with its namespace service`) } } @@ -306,54 +356,92 @@ class ScopedRemoteNamespace { name: string, private readonly invokeRemote: InvokeRemote, ) { - this.ctx = ctx - this.ownerCtx = ctx - this.name = name - Object.defineProperty(this, symbols.tracker, { - value: { associate: name, property: 'ctx' }, - }) + super(ctx, remoteServiceKey(name)) + this.namespace = name } assertMethodAvailable(method: string): void { - ScopedRemoteNamespace.assertMethodAvailable(this.name, method) - if (method in this) { - throw new Error(`client api: scoped method ${JSON.stringify(`${this.name}/${method}`)} conflicts with its namespace service`) + RemoteNamespaceService.assertMethodAvailable(this.namespace, method) + if (method in this && !this.methods.has(method)) { + throw new Error(`client api: method ${JSON.stringify(`${this.namespace}/${method}`)} conflicts with its namespace service`) } } - install(descriptor: InvocationDescriptor, projection: ScopedProjection, token: MountToken): void { - this.assertMethodAvailable(descriptor.method) - const activate = this.methods.size === 0 - const method = descriptor.method + get empty(): boolean { + return this.methods.size === 0 + } + + has(kind: 'direct' | 'scoped', method: string): boolean { + return this.methods.get(method)?.[kind] !== undefined + } + + installDirect(descriptor: InvocationDescriptor, token: MountToken): void { + this.install(descriptor.method, 'direct', { descriptor, token }) + } + + installScoped(descriptor: InvocationDescriptor, projection: ScopedProjection, token: MountToken): void { + this.install(descriptor.method, 'scoped', { descriptor, projection, token }) + } + + private install(method: string, kind: 'direct', value: DirectMethod): void + private install(method: string, kind: 'scoped', value: ScopedMethod): void + private install(method: string, kind: 'direct' | 'scoped', value: DirectMethod | ScopedMethod): void { + this.assertMethodAvailable(method) + let record = this.methods.get(method) + const fresh = record === undefined + record ??= {} + if (record[kind] !== undefined) { + throw new Error(`client api: ${kind} method ${this.namespace}/${method} is already mounted`) + } try { - Object.defineProperty(this, method, { - configurable: true, - enumerable: true, - value: function (this: ScopedRemoteNamespace, ...args: unknown[]): Promise { - return this.invokeRemote(descriptor, projection, token, this.ctx, args) - }, - }) - if (activate) { - this.disposeService = this.ownerCtx.reflect.provide(this.name, this) + if (fresh) { + Object.defineProperty(this, method, { + configurable: true, + enumerable: true, + get: function (this: RemoteNamespaceService): (...args: unknown[]) => Promise { + const callerCtx = this.ctx + const current = this.methods.get(method) + const direct = current?.direct + const scoped = current?.scoped + return (...args: unknown[]) => { + return this.invokeRemote(direct, scoped, callerCtx, args) + } + }, + }) + this.methods.set(method, record) } + if (kind === 'direct') record.direct = value + else record.scoped = value as ScopedMethod } catch (error) { - Reflect.deleteProperty(this, method) + if (kind === 'direct') delete record.direct + else delete record.scoped + if (fresh) { + this.methods.delete(method) + Reflect.deleteProperty(this, method) + } throw error } - this.methods.add(method) } - remove(method: string): void { - Reflect.deleteProperty(this, method) + remove(kind: 'direct' | 'scoped', method: string, token: MountToken): boolean { + const record = this.methods.get(method) + const current = record?.[kind] + /* v8 ignore next -- duplicate live variants are rejected before installation, so no newer token can replace this one. */ + if (record === undefined || current?.token !== token) return false + if (kind === 'direct') delete record.direct + else delete record.scoped + if (record.direct !== undefined || record.scoped !== undefined) return true this.methods.delete(method) - if (this.methods.size !== 0) return - const disposeService = this.disposeService - this.disposeService = undefined - void disposeService?.() + Reflect.deleteProperty(this, method) + return true } } -const SCOPED_NAMESPACE_FIELDS = new Set(['ctx', 'disposeService', 'invokeRemote', 'methods', 'name', 'ownerCtx']) +const REMOTE_NAMESPACE_FIELDS = new Set(['ctx', 'empty', 'invokeRemote', 'methods', 'name', 'namespace']) + +function remoteServiceKey(namespace: string): string { + return `remote.${namespace}` +} function endpointOf(descriptor: Pick): string { return `${descriptor.namespace}/${descriptor.method}` diff --git a/packages/api/gateway/tests/client.spec.ts b/packages/api/gateway/tests/client.spec.ts index feae3056c9..216f2359e7 100644 --- a/packages/api/gateway/tests/client.spec.ts +++ b/packages/api/gateway/tests/client.spec.ts @@ -1,4 +1,4 @@ -import { Context } from 'cordis' +import { Context, Service } from 'cordis' import { describe, expect, it, vi } from 'vitest' import { z } from 'zod' import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' @@ -38,7 +38,7 @@ declare module '@deepseek-ai/dsh-type-meta' { } -type FixtureContext = Context & TypeRTRemoteContextApi<'fixture'> +type FixtureContext = Omit & { readonly remote: TypeRTRemoteContextApi<'fixture'> } const idSchema = z.string().min(1) const requestSchema = z.object({ objective: z.string().min(1) }) @@ -105,17 +105,16 @@ describe('Client TypeRT API', () => { const call = vi.fn() .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } }) const ctx = await bench(call) - let retained: typeof ctx.api.goals.create | undefined + const businessGoals = { owner: 'host business service' } + const disposeBusinessGoals = ctx.provide('goals', businessGoals) const assembly = ctx.plugin(Object.assign( - (scope: Context) => { - scope.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }) - retained = scope.api.goals.create - }, - { inject: ['api'] }, + (scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }), + { inject: ['remote'] }, )) await assembly + const retained = ctx.remote.goals.create - await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).resolves.toEqual({ ref: 'goal-1' }) + await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).resolves.toEqual({ ref: 'goal-1' }) expect(call).toHaveBeenCalledWith( '/api', 'goals/create', @@ -123,7 +122,7 @@ describe('Client TypeRT API', () => { expect.any(AbortSignal), ) const callerAbort = new AbortController() - await expect(ctx.api.goals.create( + await expect(ctx.remote.goals.create( 'agent-1', { objective: 'cancel me' }, callerAbort.signal, @@ -135,16 +134,18 @@ describe('Client TypeRT API', () => { callerAbort.abort(cancellation) expect(combinedSignal?.aborted).toBe(true) expect(combinedSignal?.reason).toBe(cancellation) - await expect(ctx.api.goals.create('', { objective: 'ship' })).rejects.toThrow('rejected "agentId"') + await expect(ctx.remote.goals.create('', { objective: 'ship' })).rejects.toThrow('rejected "agentId"') call.mockResolvedValueOnce({ ok: true, value: { ref: 1 } }) - await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('rejected "result"') + await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('rejected "result"') await assembly.dispose() - expect((ctx.api as unknown as Record).goals).toBeUndefined() - expect(ctx.get('goals')).toBeUndefined() + expect((ctx.remote as unknown as Record).goals).toBeUndefined() + expect(ctx.get('remote.goals')).toBeUndefined() + expect(ctx.get('goals')).toBe(businessGoals) expect(ctx.typert.remotes.list()).toEqual([]) await expect(retained?.('agent-1', { objective: 'ship' })).rejects.toThrow('no longer mounted') + disposeBusinessGoals() }) it('projects one direct lookup descriptor onto an Agent-scoped alias', async () => { @@ -156,26 +157,24 @@ describe('Client TypeRT API', () => { identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId, }) const assembly = ctx.plugin(Object.assign( - (scope: Context) => { - scope.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }) - }, - { inject: ['api'] }, + (scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }), + { inject: ['remote'] }, )) await assembly - await expect(agentCtx.goals.create({ objective: 'ship scoped' })).resolves.toEqual({ ref: 'goal-2' }) + await expect(agentCtx.remote.goals.create({ objective: 'ship scoped' })).resolves.toEqual({ ref: 'goal-2' }) expect(call).toHaveBeenCalledWith( '/api', 'goals/create', { args: { agentId: 'agent-2', request: { objective: 'ship scoped' } } }, expect.any(AbortSignal), ) - await expect((ctx as FixtureContext).goals.create({ objective: 'wrong scope' })) - .rejects.toThrow('requires a "fixture" Context') + await expect((ctx as FixtureContext).remote.goals.create({ objective: 'wrong scope' })) + .rejects.toThrow('expected 2 business argument(s)') await assembly.dispose() - expect((ctx.api as unknown as Record).goals).toBeUndefined() - expect(ctx.get('goals')).toBeUndefined() + expect((ctx.remote as unknown as Record).goals).toBeUndefined() + expect(ctx.get('remote.goals')).toBeUndefined() }) it('uses the caller Context identity for scoped namespace methods', async () => { @@ -187,25 +186,23 @@ describe('Client TypeRT API', () => { identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId, }) const assembly = ctx.plugin(Object.assign( - (scope: Context) => { - scope.api.mount({ package: '@fixture/goals', descriptors: [contextDescriptor()] }) - }, - { inject: ['api'] }, + (scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [contextDescriptor()] }), + { inject: ['remote'] }, )) await assembly - await expect(agentCtx.goals.rename({ objective: 'land' })).resolves.toEqual({ renamed: true }) + await expect(agentCtx.remote.goals.rename({ objective: 'land' })).resolves.toEqual({ renamed: true }) expect(call).toHaveBeenCalledWith( '/api', 'goals/rename', { args: { agentId: 'agent-2', request: { objective: 'land' } } }, expect.any(AbortSignal), ) - await expect((ctx as FixtureContext).goals.rename({ objective: 'land' })) + await expect((ctx as FixtureContext).remote.goals.rename({ objective: 'land' })) .rejects.toThrow('requires a "fixture" Context') await assembly.dispose() - expect(ctx.get('goals')).toBeUndefined() + expect(ctx.get('remote.goals')).toBeUndefined() }) it('rejects weak descriptors and namespace collisions before registration', async () => { @@ -215,12 +212,12 @@ describe('Client TypeRT API', () => { result: { mode: 'src-json' }, } - expect(() => ctx.api.mount({ package: '@fixture/weak', descriptors: [weak] })) - .toThrow('has no strict codec') - expect(() => ctx.api.mount({ + await expect(ctx.remote.$mount({ package: '@fixture/weak', descriptors: [weak] })) + .rejects.toThrow('has no strict codec') + await expect(ctx.remote.$mount({ package: '@fixture/conflict', - descriptors: [{ ...directDescriptor(), namespace: 'mount' }], - })).toThrow('conflicts with the API service') + descriptors: [{ ...directDescriptor(), namespace: '$mount' }], + })).rejects.toThrow('conflicts with the Remote service') expect(ctx.typert.remotes.list()).toEqual([]) }) @@ -235,48 +232,50 @@ describe('Client TypeRT API', () => { const direct = directDescriptor() const context = contextDescriptor() - expect(() => ctx.api.mount({ + await expect(ctx.remote.$mount({ package: '@fixture/direct-duplicates', descriptors: [direct, { ...direct, id: '@fixture/goals#goals/create-again' }], - })).toThrow('repeats direct method') - expect(() => ctx.api.mount({ + })).rejects.toThrow('repeats direct method') + await expect(ctx.remote.$mount({ package: '@fixture/scoped-duplicates', descriptors: [context, { ...context, id: '@fixture/goals#goals/rename-again' }], - })).toThrow('repeats scoped method') + })).rejects.toThrow('repeats scoped method') - const disposeDirect = ctx.api.mount({ package: '@fixture/direct-live', descriptors: [direct] }) - expect(() => ctx.api.mount({ + const disposeDirect = await ctx.remote.$mount({ package: '@fixture/direct-live', descriptors: [direct] }) + await expect(ctx.remote.$mount({ package: '@fixture/direct-conflict', descriptors: [{ ...direct, id: '@fixture/other#goals/create' }], - })).toThrow('direct method goals/create is already mounted') + })).rejects.toThrow('direct method goals/create is already mounted') await disposeDirect() - const disposeScoped = ctx.api.mount({ package: '@fixture/scoped-live', descriptors: [context] }) - expect(() => ctx.api.mount({ + const disposeScoped = await ctx.remote.$mount({ package: '@fixture/scoped-live', descriptors: [context] }) + await expect(ctx.remote.$mount({ package: '@fixture/scoped-conflict', descriptors: [{ ...context, id: '@fixture/other#goals/rename' }], - })).toThrow('scoped method goals/rename is already mounted') - expect(() => ctx.api.mount({ + })).rejects.toThrow('scoped method goals/rename is already mounted') + await expect(ctx.remote.$mount({ package: '@fixture/service-method-conflict', descriptors: [{ ...context, id: '@fixture/goals#goals/remove', method: 'remove' }], - })).toThrow('conflicts with its namespace service') - const scopedService = ctx.get('goals') as unknown as object + })).rejects.toThrow('conflicts with its namespace service') + const scopedService = ctx.get('remote.goals') as unknown as object Object.defineProperty(scopedService, 'custom', { configurable: true, value: () => undefined }) - expect(() => ctx.api.mount({ + await expect(ctx.remote.$mount({ package: '@fixture/service-own-property-conflict', descriptors: [{ ...direct, id: '@fixture/goals#goals/custom', method: 'custom' }], - })).toThrow('conflicts with its namespace service') + })).rejects.toThrow('conflicts with its namespace service') Reflect.deleteProperty(scopedService, 'custom') await disposeScoped() - expect(() => ctx.api.mount({ + const disposeRemoteTypert = ctx.reflect.provide('remote.typert', { owner: 'fixture' }) + await expect(ctx.remote.$mount({ package: '@fixture/context-property-conflict', descriptors: [{ ...context, namespace: 'typert' }], - })).toThrow('conflicts with an existing Context property') + })).rejects.toThrow('conflicts with an existing Remote namespace') + await disposeRemoteTypert() - const disposeMultipleScoped = ctx.api.mount({ + const disposeMultipleScoped = await ctx.remote.$mount({ package: '@fixture/multiple-scoped', descriptors: [directDescriptor(), contextDescriptor()], }) - await expect(agentCtx.goals.rename({ objective: 'remounted' })).resolves.toEqual({ renamed: true }) + await expect(agentCtx.remote.goals.rename({ objective: 'remounted' })).resolves.toEqual({ renamed: true }) expect(call).toHaveBeenLastCalledWith( '/api', 'goals/rename', @@ -286,41 +285,6 @@ describe('Client TypeRT API', () => { await disposeMultipleScoped() }) - it('rolls back direct projection when scoped installation fails', async () => { - const ctx = await bench(vi.fn()) - const disposeScoped = ctx.api.mount({ - package: '@fixture/scoped-base', - descriptors: [contextDescriptor()], - }) - const defineProperty = Object.defineProperty - let createDefinitions = 0 - const definePropertySpy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => { - // The direct projection defines `create` first; fail the following scoped projection. - if (key === 'create' && ++createDefinitions === 2) throw new Error('simulated scoped installation failure') - return defineProperty(target, key, attributes) - }) - - try { - expect(() => ctx.api.mount({ - package: '@fixture/failing-install', - descriptors: [directDescriptor()], - })).toThrow('simulated scoped installation failure') - } finally { - definePropertySpy.mockRestore() - } - - expect((ctx.api as unknown as Record).goals).toBeUndefined() - expect(ctx.get('goals') !== undefined).toBe(true) - expect(ctx.typert.remotes.list()).toHaveLength(1) - - const disposeRetry = ctx.api.mount({ - package: '@fixture/retry', - descriptors: [directDescriptor()], - }) - await disposeRetry() - await disposeScoped() - }) - it('rolls back earlier descriptors when a later descriptor fails to install', async () => { const ctx = await bench(vi.fn()) const { scope: _scope, ...first } = directDescriptor() @@ -335,17 +299,17 @@ describe('Client TypeRT API', () => { return defineProperty(target, key, attributes) }) try { - expect(() => ctx.api.mount({ package: '@fixture/failing-batch', descriptors: [first, second] })) - .toThrow('fixture later-descriptor failure') + await expect(ctx.remote.$mount({ package: '@fixture/failing-batch', descriptors: [first, second] })) + .rejects.toThrow('fixture later-descriptor failure') } finally { spy.mockRestore() } - expect((ctx.api as unknown as Record).goals).toBeUndefined() + expect((ctx.remote as unknown as Record).goals).toBeUndefined() await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) - const retry = ctx.api.mount({ package: '@fixture/retry-batch', descriptors: [first, second] }) - expect(ctx.api.goals.create).toBeTypeOf('function') - expect((ctx.api.goals as unknown as Record).archive).toBeTypeOf('function') + const retry = await ctx.remote.$mount({ package: '@fixture/retry-batch', descriptors: [first, second] }) + expect(ctx.remote.goals.create).toBeTypeOf('function') + expect((ctx.remote.goals as unknown as Record).archive).toBeTypeOf('function') await retry() }) @@ -353,7 +317,7 @@ describe('Client TypeRT API', () => { const ctx = await bench(vi.fn()) const direct = directDescriptor() const context = contextDescriptor() - expect(() => ctx.api.mount({ + await expect(ctx.remote.$mount({ package: '@fixture/weak-parameter', descriptors: [{ ...direct, @@ -361,19 +325,19 @@ describe('Client TypeRT API', () => { ? { ...parameter, codec: { mode: 'src-json' } } : parameter), }], - })).toThrow('has no strict codec') - expect(() => ctx.api.mount({ + })).rejects.toThrow('has no strict codec') + await expect(ctx.remote.$mount({ package: '@fixture/weak-context', descriptors: [{ ...context, invocation: { ...context.invocation, codec: { mode: 'src-json' } }, } as InvocationDescriptor], - })).toThrow('has no strict codec') - expect(() => ctx.api.mount({ + })).rejects.toThrow('has no strict codec') + await expect(ctx.remote.$mount({ package: '@fixture/malformed-scope', descriptors: [{ ...direct, scope: { context: 'fixture', wire: 'missingId' } }], - })).toThrow('scope must select its only lookup parameter') - expect(() => ctx.api.mount({ + })).rejects.toThrow('scope must select its only lookup parameter') + await expect(ctx.remote.$mount({ package: '@fixture/ambiguous-scope', descriptors: [{ ...direct, @@ -382,7 +346,7 @@ describe('Client TypeRT API', () => { codec: { mode: 'strict', typeSymbol: '@fixture#AgentId', schema: idSchema }, }], }], - })).toThrow('scope must select its only lookup parameter') + })).rejects.toThrow('scope must select its only lookup parameter') }) it('validates invocation arity, required binders, live Connection, and mutable descriptor codecs', async () => { @@ -390,27 +354,29 @@ describe('Client TypeRT API', () => { .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } }) const ctx = await bench(call) const descriptor = directDescriptor() - const dispose = ctx.api.mount({ + const dispose = await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [descriptor, contextDescriptor()], }) - const create = ctx.api.goals.create as unknown as (...args: unknown[]) => Promise - const goals = (ctx as FixtureContext).goals + const create = ctx.remote.goals.create as unknown as (...args: unknown[]) => Promise + const goals = (ctx as FixtureContext).remote.goals const rename = goals.rename as unknown as (...args: unknown[]) => Promise await expect(create('agent-1')).rejects.toThrow('expected 2 business argument(s) plus an optional AbortSignal, got 1') await expect(create('agent-1', { objective: 'ship' }, undefined, 'extra')) .rejects.toThrow('got 4') await expect(rename.call(goals)).rejects.toThrow('expected 1 argument(s), got 0') - await expect((ctx as FixtureContext).goals.create({ objective: 'ship' })) + await expect((ctx as FixtureContext).remote.goals.create({ objective: 'ship' })) + .rejects.toThrow('expected 2 business argument(s)') + await expect((ctx as FixtureContext).remote.goals.rename({ objective: 'ship' })) .rejects.toThrow('no Client Context binder') ;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'src-json' - await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('has no strict codec') + await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('has no strict codec') ;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'strict' ctx.set('connection', undefined) - await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('no active Connection') + await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('no active Connection') await dispose() }) @@ -427,14 +393,14 @@ describe('Client TypeRT API', () => { id: '@fixture/goals#goals/archive', method: 'archive', } - const dispose = ctx.api.mount({ package: '@fixture/goals', descriptors: [first, second] }) - const invocation = ctx.api.goals.create('agent-1', { objective: 'ship' }) + const dispose = await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [first, second] }) + const invocation = ctx.remote.goals.create('agent-1', { objective: 'ship' }) await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) }) await dispose() resolveCall({ ok: true, value: { ref: 'goal-1' } }) await expect(invocation).rejects.toThrow('withdrawn during invocation') - expect((ctx.api as unknown as Record).goals).toBeUndefined() + expect((ctx.remote as unknown as Record).goals).toBeUndefined() }) it('preserves a __proto__ wire parameter as an own named argument', async () => { @@ -453,9 +419,9 @@ describe('Client TypeRT API', () => { codec: { mode: 'strict', typeSymbol: '@fixture#PrototypeValue', schema: z.string() }, }], } - const dispose = ctx.api.mount({ package: '@fixture/prototype', descriptors: [descriptor] }) + const dispose = await ctx.remote.$mount({ package: '@fixture/prototype', descriptors: [descriptor] }) - const method = (ctx.api.goals as unknown as Record Promise>).prototype + const method = (ctx.remote.goals as unknown as Record Promise>).prototype await expect(method?.('wire-value')).resolves.toEqual({ ref: 'goal-1' }) const payload = call.mock.calls[0]?.[2] as { readonly args: Record } expect(Object.getPrototypeOf(payload.args)).toBeNull() @@ -464,23 +430,23 @@ describe('Client TypeRT API', () => { await dispose() }) - it('rolls back Remote registration when concrete method installation fails', async () => { + it('rolls back Remote registration when namespace Service startup fails', async () => { const ctx = await bench(vi.fn()) const defineProperty = Object.defineProperty const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => { - if (key === 'goals') throw new Error('fixture installation failure') + if (key === Service.tracker) throw new Error('fixture namespace startup failure') return defineProperty(target, key, attributes) }) try { - expect(() => ctx.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })) - .toThrow('fixture installation failure') + await expect(ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })) + .rejects.toThrow('fixture namespace startup failure') await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) } finally { spy.mockRestore() } - const retry = ctx.api.mount({ package: '@fixture/goals-retry', descriptors: [directDescriptor()] }) - expect(ctx.api.goals.create).toBeTypeOf('function') + const retry = await ctx.remote.$mount({ package: '@fixture/goals-retry', descriptors: [directDescriptor()] }) + expect(ctx.remote.goals.create).toBeTypeOf('function') await retry() }) @@ -492,16 +458,21 @@ describe('Client TypeRT API', () => { return defineProperty(target, key, attributes) }) try { - expect(() => ctx.api.mount({ package: '@fixture/direct-method-failure', descriptors: [directDescriptor()] })) - .toThrow('fixture direct method installation failure') + await expect(ctx.remote.$mount({ + package: '@fixture/direct-method-failure', + descriptors: [directDescriptor()], + })).rejects.toThrow('fixture direct method installation failure') } finally { spy.mockRestore() } - expect((ctx.api as unknown as Record).goals).toBeUndefined() + expect((ctx.remote as unknown as Record).goals).toBeUndefined() await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) - const retry = ctx.api.mount({ package: '@fixture/direct-method-retry', descriptors: [directDescriptor()] }) - expect(ctx.api.goals.create).toBeTypeOf('function') + const retry = await ctx.remote.$mount({ + package: '@fixture/direct-method-retry', + descriptors: [directDescriptor()], + }) + expect(ctx.remote.goals.create).toBeTypeOf('function') await retry() }) @@ -513,41 +484,41 @@ describe('Client TypeRT API', () => { return defineProperty(target, key, attributes) }) try { - expect(() => ctx.api.mount({ package: '@fixture/scoped-failure', descriptors: [contextDescriptor()] })) - .toThrow('fixture scoped installation failure') + await expect(ctx.remote.$mount({ package: '@fixture/scoped-failure', descriptors: [contextDescriptor()] })) + .rejects.toThrow('fixture scoped installation failure') } finally { spy.mockRestore() } - expect(ctx.get('goals')).toBeUndefined() + expect(ctx.get('remote.goals')).toBeUndefined() await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) - const retry = ctx.api.mount({ package: '@fixture/scoped-retry', descriptors: [contextDescriptor()] }) - expect((ctx.get('goals') as unknown as Record).rename).toBeTypeOf('function') + const retry = await ctx.remote.$mount({ package: '@fixture/scoped-retry', descriptors: [contextDescriptor()] }) + expect((ctx.get('remote.goals') as unknown as Record).rename).toBeTypeOf('function') await retry() }) it('unregisters an empty scoped namespace so another provider can claim its name', async () => { const ctx = await bench(vi.fn()) - const dispose = ctx.api.mount({ package: '@fixture/scoped', descriptors: [contextDescriptor()] }) - expect(ctx.get('goals')).toBeDefined() + const dispose = await ctx.remote.$mount({ package: '@fixture/scoped', descriptors: [contextDescriptor()] }) + expect(ctx.get('remote.goals')).toBeDefined() await dispose() - expect(ctx.get('goals')).toBeUndefined() + expect(ctx.get('remote.goals')).toBeUndefined() const replacement = { owner: 'replacement' } - const disposeReplacement = ctx.reflect.provide('goals', replacement) - expect(ctx.get('goals')).toBe(replacement) + const disposeReplacement = ctx.reflect.provide('remote.goals', replacement) + expect(ctx.get('remote.goals')).toBe(replacement) await disposeReplacement() }) it('throws RPC failures with the structured error as its cause', async () => { const rpcError = { code: 'internal' as const, message: 'host failed', details: {} } const ctx = await bench(vi.fn().mockResolvedValue({ ok: false, error: rpcError })) - ctx.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }) + await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }) let failure: unknown try { - await ctx.api.goals.create('agent-1', { objective: 'ship' }) + await ctx.remote.goals.create('agent-1', { objective: 'ship' }) } catch (error) { failure = error } diff --git a/packages/api/remotes/README.i18n.yaml b/packages/api/remotes/README.i18n.yaml index c3c13a8049..82947331c5 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: cf54a56a849246d4efdca09cadd42e157064bdee -README.zh.md: 5cd7ef21c926440ca4df6d88ee4adfe87defcc3f +README.md: 7f6a2114d900413d972584c0f1c141b7f835ba36 +README.zh.md: cce263747d696570f362811556fa6f5c0be0a0f5 diff --git a/packages/api/remotes/README.md b/packages/api/remotes/README.md index cf54a56a84..7f6a2114d9 100644 --- a/packages/api/remotes/README.md +++ b/packages/api/remotes/README.md @@ -2,13 +2,13 @@ English | [中文](README.zh.md) -Two-sided BFF for Host Remote capabilities selected by this application. The Host entry owns Agent/Session identity policy; the Client entry imports generated `/remote` artifacts as runtime values, mounts each contribution through `ctx.api`, and re-exports their declaration merges. Client business packages depend on this facade rather than the Gateway implementation or individual Remote runtime entries. +Two-sided BFF for Host Remote capabilities selected by this application. The Host entry owns Agent/Session identity policy; the Client entry imports generated `/remote` artifacts as runtime values, mounts each contribution through `ctx.remote.$mount()`, and re-exports their declaration merges. Client business packages depend on this facade rather than the Gateway implementation or individual Remote runtime entries. `createApiRemoteAgentResolver()` reuses live Agents, resumes ordinary cold sessions, deduplicates concurrent resumes, preserves the subagent ownership fence, and configures the same resolver for TypeRT `agent` and `session` lookups. The standard Web API Proxy supplies its Agent defaults and scope setup, then uses the returned resolver for legacy methods, so migrated and unmigrated methods share one policy implementation. -The current Client assembly mounts only the Goal Remote contribution. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, concrete root and scoped methods, invocation, and cancellation. The Client entry consumes the shared `TypeRTClientApi` interface through Cordis and does not import the concrete Gateway. +The current Client assembly mounts only the Goal Remote contribution. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, and cancellation. The Client entry consumes the shared `TypeRTClientRemote` interface through Cordis and does not import the concrete Gateway. -This package contains no transport or Host service discovery logic. Its Client face can be reused by Web or a future TUI that provides the same React-free `ctx.api` contract. +This package contains no transport or Host service discovery logic. Its Client face can be reused by Web or a future TUI that provides the same React-free `ctx.remote` contract. ## Model Experience diff --git a/packages/api/remotes/README.zh.md b/packages/api/remotes/README.zh.md index 5cd7ef21c9..cce263747d 100644 --- a/packages/api/remotes/README.zh.md +++ b/packages/api/remotes/README.zh.md @@ -2,13 +2,13 @@ [English](README.md) | 中文 -为本应用选定的 Host Remote 能力提供双侧 BFF。Host 入口负责 Agent/Session 身份策略;Client 入口以运行时值形式导入生成的 `/remote` 产物,通过 `ctx.api` 挂载每项贡献,并重新导出对应的声明合并。Client 业务包依赖该外观,而不依赖 Gateway 实现或单独的 Remote 运行时入口。 +为本应用选定的 Host Remote 能力提供双侧 BFF。Host 入口负责 Agent/Session 身份策略;Client 入口以运行时值形式导入生成的 `/remote` 产物,通过 `ctx.remote.$mount()` 挂载每项贡献,并重新导出对应的声明合并。Client 业务包依赖该外观,而不依赖 Gateway 实现或单独的 Remote 运行时入口。 `createApiRemoteAgentResolver()` 会复用 live Agent、恢复普通冷会话、对并发恢复去重、保留 subagent ownership fence,并为 TypeRT `agent` 和 `session` lookup 配置同一个 resolver。标准 Web API Proxy 提供 Agent 默认值和 scope 设置,再将返回的 resolver 用于旧方法,使已迁移与未迁移方法共用同一份策略实现。 -当前 Client 组合仅挂载 Goal Remote 贡献。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、具体的根级方法和作用域方法、调用与取消。Client 入口通过 Cordis 消费共享的 `TypeRTClientApi` 接口,不导入具体 Gateway。 +当前 Client 组合仅挂载 Goal Remote 贡献。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪 namespace Service、直接与作用域方法、调用与取消。Client 入口通过 Cordis 消费共享的 `TypeRTClientRemote` 接口,不导入具体 Gateway。 -本包不包含传输逻辑或 Host 服务发现逻辑。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.api` 契约,均可复用其 Client face。 +本包不包含传输逻辑或 Host 服务发现逻辑。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.remote` 契约,均可复用其 Client face。 ## 模型体验 diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 1bc36b62ee..ebd342300e 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -2,25 +2,25 @@ import type { Context } from 'cordis' import goalsRemote from '@deepseek-ai/dsh-goal/remote' -import type { TypeRTClientApi } from '@deepseek-ai/dsh-type-meta' +import type { TypeRTClientRemote } from '@deepseek-ai/dsh-type-meta' -export type { TypeRTClientApi as ClientApi } from '@deepseek-ai/dsh-type-meta' +export type { TypeRTClientRemote as ClientRemote } from '@deepseek-ai/dsh-type-meta' export type {} from '@deepseek-ai/dsh-goal/remote' declare module 'cordis' { interface Context { - /** Generated direct Remote namespaces selected by this Client assembly. */ - api: TypeRTClientApi + /** Generated Remote namespaces selected by this Client assembly. */ + remote: TypeRTClientRemote } } -/** Required service: the typed Client API contribution mount. */ -export const inject = ['api'] +/** Required service: the typed Client Remote contribution mount. */ +export const inject = ['remote'] /** * Mount the Host capabilities explicitly selected for this Client assembly. * @param ctx - Client Cordis root carrying the typed API service. */ -export function apply(ctx: Context): void { - ctx.api.mount(goalsRemote) +export function apply(ctx: Context): Promise<() => Promise> { + return ctx.remote.$mount(goalsRemote) } diff --git a/packages/api/remotes/tests/built-lib.e2e.ts b/packages/api/remotes/tests/built-lib.e2e.ts index b8f6c81e98..af584cba7f 100644 --- a/packages/api/remotes/tests/built-lib.e2e.ts +++ b/packages/api/remotes/tests/built-lib.e2e.ts @@ -143,18 +143,18 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { let invalidRejected = false try { - await client.api.goals.create(rootAgent.id, { objective: 1 }) + await client.remote.goals.create(rootAgent.id, { objective: 1 }) } catch { invalidRejected = true } - const rootResult = await client.api.goals.create(rootAgent.id, { objective: 'root goal' }) - const rootEdit = await client.api.goals.edit( + const rootResult = await client.remote.goals.create(rootAgent.id, { objective: 'root goal' }) + const rootEdit = await client.remote.goals.edit( rootAgent.id, rootResult.ref, { objective: 'edited root goal' }, ) const agentContext = client.extend({ builtAgentId: scopedAgent.id }) - const scopedResult = await agentContext.goals.create({ objective: 'scoped goal', maxGoalRounds: 3 }) + const scopedResult = await agentContext.remote.goals.create({ objective: 'scoped goal', maxGoalRounds: 3 }) const result = { invalidRejected, rootResult, diff --git a/packages/client/runtime/src/client/agents/scope.ts b/packages/client/runtime/src/client/agents/scope.ts index ba4fd8ede7..1154d10feb 100644 --- a/packages/client/runtime/src/client/agents/scope.ts +++ b/packages/client/runtime/src/client/agents/scope.ts @@ -18,7 +18,12 @@ import { Context as CordisContext } from 'cordis' import type { Context, Fiber } from 'cordis' import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' -import type { TypeRTRemoteContextApi } from '@deepseek-ai/dsh-type-meta' +import type { TypeRTClientRemote, TypeRTRemoteContextApi } from '@deepseek-ai/dsh-type-meta' + +/** Client Cordis Context carrying one Agent identity and its scoped Remote namespaces. */ +export type AgentContext = Omit & { + readonly remote: TypeRTClientRemote & TypeRTRemoteContextApi<'agent'> +} /** Context tag written by {@link createScope}. */ const kScope = Symbol('dsh.client.scope') @@ -30,7 +35,7 @@ export interface AgentScopeHandle { * through it (passing it as the dispatch subject routes to this agent's * tagged listeners plus every untagged one). */ - ctx: Context & TypeRTRemoteContextApi<'agent'> + ctx: AgentContext /** Backing fiber (dispose tears down every scope-owned registration). */ fiber: Fiber } @@ -55,7 +60,7 @@ export function createScope(ctx: Context, key: SessionId): AgentScopeHandle { const tag = scopeOf(listenerCtx) return tag === undefined || tag === key }, - }) as Context & TypeRTRemoteContextApi<'agent'> + }) as AgentContext return { fiber, ctx: scoped, diff --git a/packages/client/runtime/src/client/contract/sessions.ts b/packages/client/runtime/src/client/contract/sessions.ts index 8e9c530720..2af2ef51c8 100644 --- a/packages/client/runtime/src/client/contract/sessions.ts +++ b/packages/client/runtime/src/client/contract/sessions.ts @@ -11,8 +11,8 @@ import type { Context } from 'cordis' import type { RpcResult, SessionId, SubagentAddress, } from '@deepseek-ai/dsh-client-connection/client' -import type { TypeRTRemoteContextApi } from '@deepseek-ai/dsh-type-meta' import type { HostObservable, SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots' +import type { AgentContext } from '../agents/scope.ts' import type { SessionSearchResultItem } from '../sessions/manager.ts' import type { SessionBinding, SessionListState, SessionProvideDescriptor, @@ -20,8 +20,7 @@ import type { import type { SessionFace } from './session.ts' import type { ObservableSnapshot } from './store.ts' -/** Client Cordis Context carrying one Agent identity and its generated Remote namespaces. */ -export type AgentContext = Context & TypeRTRemoteContextApi<'agent'> +export type { AgentContext } from '../agents/scope.ts' /** The sessions-service face injected as `ctx.sessions`. */ export interface ISessions { diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index a9d2bb0d7d..b772e315a3 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -179,8 +179,8 @@ declare module 'cordis' { } } -/** Required services: the typed Remote API, wire handle, and Client TypeRT registry. */ -export const inject = ['api', 'connection', 'typert'] +/** Required services: the Remote root and Goal namespace, wire handle, and Client TypeRT registry. */ +export const inject = ['remote', 'remote.goals', 'connection', 'typert'] /** Mounts the browser runtime services and connection stream. * @param ctx - Client Cordis context. diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index 5635793122..e9b387fb00 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -38,7 +38,8 @@ async function mount(): Promise { }, } ctx.reflect.provide('connection', handle) - ctx.reflect.provide('api', {}) + ctx.reflect.provide('remote', {}) + ctx.reflect.provide('remote.goals', {}) await ctx.plugin(RuntimeClient).await() return bench } diff --git a/packages/client/runtime/tests/wire-events.spec.ts b/packages/client/runtime/tests/wire-events.spec.ts index 5ab644682a..703c5b1728 100644 --- a/packages/client/runtime/tests/wire-events.spec.ts +++ b/packages/client/runtime/tests/wire-events.spec.ts @@ -32,7 +32,8 @@ async function mount(): Promise { }, } ctx.reflect.provide('connection', handle) - ctx.reflect.provide('api', {}) + ctx.reflect.provide('remote', {}) + ctx.reflect.provide('remote.goals', {}) await ctx.plugin(RuntimeClient).await() return bench } diff --git a/packages/client/ui-goal/README.i18n.yaml b/packages/client/ui-goal/README.i18n.yaml index f30f14ed48..55853ef4bd 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: b99aaf624a7d669879ba668938ee455e3cdc68ad -README.zh.md: 3d823d013066bc912398f61c85553887e05ca3b4 +README.md: a53fb3a89eaee364cb025ca728ca42ce934887b0 +README.zh.md: 1ad9f50aee5b103f6455e4d4b7d29fa9eb29a108 diff --git a/packages/client/ui-goal/README.md b/packages/client/ui-goal/README.md index b99aaf624a..a53fb3a89e 100644 --- a/packages/client/ui-goal/README.md +++ b/packages/client/ui-goal/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Goal surface plugin, browser half: the `GoalBar` strip is the second standalone card in the `conversation.input.dock` composer-context stack (order 10, after Todo and before Queue). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no domain store, refresh chain, or event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear through `ctx.api.goals` — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the rejected Remote error inline. The strip single-flights mutations synchronously because React's pending render cannot fence same-frame clicks; after a successful clear it immediately suppresses that exact goal id while the authoritative null projection catches up. Goal creation stays on the `/goal` host command; loading, absent, completed, and successfully cleared goals render nothing. +Goal surface plugin, browser half: the `GoalBar` strip is the second standalone card in the `conversation.input.dock` composer-context stack (order 10, after Todo and before Queue). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no domain store, refresh chain, or event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear through `ctx.remote.goals` — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the rejected Remote error inline. The strip single-flights mutations synchronously because React's pending render cannot fence same-frame clicks; after a successful clear it immediately suppresses that exact goal id while the authoritative null projection catches up. Goal creation stays on the `/goal` host command; loading, absent, completed, and successfully cleared goals render nothing. The `/client` export surface is the plugin body (`apply`/`inject`), the `GoalBar`/`GoalDock` components, and the injected verb face types. diff --git a/packages/client/ui-goal/README.zh.md b/packages/client/ui-goal/README.zh.md index 3d823d0130..1ad9f50aee 100644 --- a/packages/client/ui-goal/README.zh.md +++ b/packages/client/ui-goal/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Goal 界面插件(浏览器端部分):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第二张独立卡片(order 10,位于 Todo 之后、Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有领域 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词(edit / pause / resume / clear,经 `ctx.api.goals` 调用——active 的 goal 提供暂停动作,paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref,并将 Remote 调用的拒绝错误内联呈现。由于 React 的 pending 渲染无法拦住同一帧内的点击,横条会同步为变更建立 single-flight 防护;清除成功后,会立即抑制该 goal id 对应的目标显示,直到权威的 null 投影追上。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成和已成功清除的 goal 一律不渲染。 +Goal 界面插件(浏览器端部分):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第二张独立卡片(order 10,位于 Todo 之后、Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有领域 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词(edit / pause / resume / clear,经 `ctx.remote.goals` 调用——active 的 goal 提供暂停动作,paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref,并将 Remote 调用的拒绝错误内联呈现。由于 React 的 pending 渲染无法拦住同一帧内的点击,横条会同步为变更建立 single-flight 防护;清除成功后,会立即抑制该 goal id 对应的目标显示,直到权威的 null 投影追上。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成和已成功清除的 goal 一律不渲染。 `/client` 的导出接口包括插件本体(`apply`/`inject`)、`GoalBar`/`GoalDock` 组件与注入动词面类型。 diff --git a/packages/client/ui-goal/src/client/index.ts b/packages/client/ui-goal/src/client/index.ts index bea4f67df2..2c041e0eae 100644 --- a/packages/client/ui-goal/src/client/index.ts +++ b/packages/client/ui-goal/src/client/index.ts @@ -9,7 +9,7 @@ * Goal creation stays on the /goal host command. */ import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' -// Type-only: pulls the generated Remote API and ctx.api merge through the Client assembly boundary. +// Type-only: pulls the generated Remote API and ctx.remote merge through the Client assembly boundary. import type {} from '@deepseek-ai/dsh-api-remotes/client' // Type-only: pulls the ui-conversation SlotMap merge (the input.dock entry). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -36,7 +36,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { const NS = 'goal' /** Required services: slots for the dock entry, sessions for the projected ref, API for Remote mutations, locale for the copy. */ -export const inject = ['slots', 'sessions', 'api', 'locale'] +export const inject = ['slots', 'sessions', 'remote', 'remote.goals', 'locale'] /** Map one generated Remote call, including synchronous namespace lookup failures, onto the strip's inline-render shape. */ async function settle(invoke: () => Promise): Promise { @@ -94,22 +94,22 @@ export function apply(ctx: ClientContext): void { onEdit: async (objective) => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(() => ctx.api.goals.edit(sessionId, ref, { objective })) + return settle(() => ctx.remote.goals.edit(sessionId, ref, { objective })) }, onPause: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(() => ctx.api.goals.pause(sessionId, ref)) + return settle(() => ctx.remote.goals.pause(sessionId, ref)) }, onResume: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(() => ctx.api.goals.resume(sessionId, ref)) + return settle(() => ctx.remote.goals.resume(sessionId, ref)) }, onClear: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(() => ctx.api.goals.clear(sessionId, ref)) + return settle(() => ctx.remote.goals.clear(sessionId, ref)) }, }), }, GoalDock)) diff --git a/packages/client/ui-goal/tests/browser-plugin.spec.tsx b/packages/client/ui-goal/tests/browser-plugin.spec.tsx index f900682712..756968136e 100644 --- a/packages/client/ui-goal/tests/browser-plugin.spec.tsx +++ b/packages/client/ui-goal/tests/browser-plugin.spec.tsx @@ -10,7 +10,7 @@ * plugin fiber (HMR safety). The node half and the invariant companion are * exercised over the same Context. */ -import { Context } from 'cordis' +import { Context, Service } from 'cordis' import { describe, expect, it, vi } from 'vitest' import { cleanup, render } from '@testing-library/react' import { afterEach } from 'vitest' @@ -71,8 +71,17 @@ async function bench(options: { clear: answer(`${prefix}/clear`, ref), }) let activeGoals: ReturnType | undefined = goals('goals') - ctx.provide('api', { - get goals() { return activeGoals }, + class RemoteService extends Service { + constructor(serviceCtx: Context) { + super(serviceCtx, 'remote') + } + } + new RemoteService(ctx) + ctx.provide('remote.goals', { + get edit() { return activeGoals?.edit }, + get pause() { return activeGoals?.pause }, + get resume() { return activeGoals?.resume }, + get clear() { return activeGoals?.clear }, }) await ctx.plugin(SlotsService).await() ctx.slots.register({ diff --git a/packages/typert/generator/tests/remote-model.spec.ts b/packages/typert/generator/tests/remote-model.spec.ts index 27bdac2fac..eaaf680cc6 100644 --- a/packages/typert/generator/tests/remote-model.spec.ts +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -593,8 +593,8 @@ const created: Promise = create('agent-1', { title: 'ship' }) const cancellable: Promise = create('agent-1', { title: 'ship' }, new AbortController().signal) const createdScoped: Promise = createScoped({ title: 'ship' }) const renamed: Promise = rename({ ref: 'goal-1', title: 'land' }) -declare const ctx: { api: TypeRTRemoteNamespaceMap } -const navigated: Promise = ctx.api.goals.create('agent-1', { title: 'navigate' }) +declare const ctx: { remote: TypeRTRemoteNamespaceMap } +const navigated: Promise = ctx.remote.goals.create('agent-1', { title: 'navigate' }) void contribution void created void cancellable @@ -643,7 +643,7 @@ void navigated readFile: path => ts.sys.readFile(path), realpath: path => ts.sys.realpath?.(path) ?? path, }) - const navigation = 'ctx.api.goals.create' + const navigation = 'ctx.remote.goals.create' const position = consumerSource.indexOf(navigation) + navigation.lastIndexOf('create') + 1 const definitions = languageService.getDefinitionAtPosition(consumerPath, position) const generatedDefinition = definitions?.find(candidate => candidate.fileName === declarationPath) @@ -672,8 +672,8 @@ function assertRemoteConsumerWithoutImportHasNoNamespace(consumerRoot: string): const consumerPath = join(consumerRoot, 'consumer-without-remote.ts') writeFileSync(consumerPath, ` import type { TypeRTRemoteNamespaceMap } from '@deepseek-ai/dsh-type-meta' -declare const ctx: { api: TypeRTRemoteNamespaceMap } -ctx.api.goals.create('agent-1', { title: 'must not compile' }) +declare const ctx: { remote: TypeRTRemoteNamespaceMap } +ctx.remote.goals.create('agent-1', { title: 'must not compile' }) `) const configPath = join(consumerRoot, 'tsconfig.consumer-without-remote.json') writeFileSync(configPath, JSON.stringify({ diff --git a/packages/typert/type-meta/src/index.ts b/packages/typert/type-meta/src/index.ts index 2f687f985f..774c6d3b32 100644 --- a/packages/typert/type-meta/src/index.ts +++ b/packages/typert/type-meta/src/index.ts @@ -41,7 +41,7 @@ export type { InvocationDescriptor, InvocationParameterDescriptor, InvocationSourceLocation, - TypeRTClientApi, + TypeRTClientRemote, TypeRTClientContextBinder, TypeRTCodec, TypeRTContext, diff --git a/packages/typert/type-meta/src/types.ts b/packages/typert/type-meta/src/types.ts index ed309b7857..5e7c20cd7c 100644 --- a/packages/typert/type-meta/src/types.ts +++ b/packages/typert/type-meta/src/types.ts @@ -77,7 +77,7 @@ export type TypeRTRemoteContextApi = { TypeRTRemoteContextNamespace } -/** Merge-extensible direct namespace surface generated for Client API services. */ +/** Merge-extensible direct namespace surface generated for Client Remote services. */ export interface TypeRTRemoteNamespaceMap {} /** Awaitable disposer returned by Cordis-owned TypeRT registrations. */ @@ -176,14 +176,14 @@ export interface TypeRTRemoteContribution { readonly descriptors: readonly InvocationDescriptor[] } -/** Client API capability implemented by the Gateway and consumed by Remote assemblies. */ -export interface TypeRTClientApi extends TypeRTRemoteNamespaceMap { +/** Client Remote capability implemented by the Gateway and consumed by Remote assemblies. */ +export interface TypeRTClientRemote extends TypeRTRemoteNamespaceMap { /** * Mount one generated Host-for-Client contribution in the caller's fiber. * @param contribution - explicitly selected Remote package artifact. - * @returns disposer withdrawing descriptors and concrete methods together. + * @returns disposer after namespace services and concrete methods are ready. */ - mount(contribution: TypeRTRemoteContribution): TypeRTDisposer + $mount(contribution: TypeRTRemoteContribution): Promise } /** From d362cdb54f228ddd0edda4771b5db64420eec04e Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:35:50 +0800 Subject: [PATCH 094/104] refactor(typert): rename RemoteContext to RemoteScope --- ...08-02-typert-remote-method-calls.i18n.yaml | 4 +-- .../2026-08-02-typert-remote-method-calls.md | 36 +++++++++---------- ...026-08-02-typert-remote-method-calls.zh.md | 36 +++++++++---------- docs/api-gateway.i18n.yaml | 4 +-- docs/api-gateway.md | 14 ++++---- docs/api-gateway.zh.md | 14 ++++---- docs/development.i18n.yaml | 4 +-- docs/development.md | 2 +- docs/development.zh.md | 2 +- packages/api/gateway/README.i18n.yaml | 4 +-- packages/api/gateway/README.md | 4 +-- packages/api/gateway/README.zh.md | 4 +-- packages/api/gateway/tests/client.spec.ts | 6 ++-- packages/api/gateway/tests/gateway.spec.ts | 10 +++--- .../client/runtime/src/client/agents/scope.ts | 4 +-- packages/typert/generator/src/analyzer.ts | 14 ++++---- packages/typert/generator/src/emitter.ts | 2 +- .../remote-model/packages/remote/src/index.ts | 4 +-- .../fixtures/remote-model/type-meta.d.ts | 4 +-- .../generator/tests/remote-model.spec.ts | 18 +++++----- packages/typert/type-meta/README.i18n.yaml | 4 +-- packages/typert/type-meta/README.md | 4 +-- packages/typert/type-meta/README.zh.md | 4 +-- packages/typert/type-meta/src/index.ts | 14 ++++---- packages/typert/type-meta/src/types.ts | 20 +++++------ .../type-meta/tests/fixtures/source-launch.ts | 4 +-- .../typert/type-meta/tests/type-meta.spec.ts | 14 ++++---- 27 files changed, 127 insertions(+), 127 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index 341bf44923..71ded0fa8d 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: a8254090e042e4b359ae74fc5c19bad8abc5ef89 -2026-08-02-typert-remote-method-calls.zh.md: f1b7e5f9c61b474379962ce007e5d6bb966e5ebd +2026-08-02-typert-remote-method-calls.md: 215c647bcd7413b92625ee670022dc7316e3045a +2026-08-02-typert-remote-method-calls.zh.md: 0ce431b7cbc948e937f722f2769b15a1d26dcec9 diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index a8254090e0..215c647bcd 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -16,7 +16,7 @@ The Host and Browser Client use separate TypeScript Programs because each side a ## Decision -A business Service extends `GatewayService` and declares callable methods with `@Remote` or `@RemoteContext()`. A Service that already has another base class may instead expose the same binding through `bindTypeRTGateway()`. TypeRT generates the Host-local reflection artifact and a platform-independent Remote consumer projection from the Host Program. The Client Program continues to generate its own local reflection artifact independently. +A business Service extends `GatewayService` and declares callable methods with `@Remote` or `@RemoteScope()`. A Service that already has another base class may instead expose the same binding through `bindTypeRTGateway()`. TypeRT generates the Host-local reflection artifact and a platform-independent Remote consumer projection from the Host Program. The Client Program continues to generate its own local reflection artifact independently. The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. The `.d.ts` exposes only methods marked with a Remote decorator and refers to the business package's single public type symbols. The `.d.ts.map` navigates consumer API methods back to their Host business method implementations. The `.js` carries endpoint, parameter, Context, and Zod information for the same contract. At the assembly layer, the Browser Client mounts the required Remote JS contributions onto the Client Remote Service. The projection and Remote abstraction remain platform-independent so that a future TUI can reuse them. @@ -64,7 +64,7 @@ export class GoalService extends GatewayService { `goals` is the explicit Cordis service key passed to `super()` and is the default wire namespace. Pass a `namespace` option as the third argument only when the protocol namespace genuinely needs to differ from the service key. -Use `@RemoteContext()` when the Service receiver must be resolved within an isolated kind of Context. Context identity does not enter the business method's parameters: +Use `@RemoteScope()` when the Service receiver must be resolved within an isolated kind of Context. Scope identity does not enter the business method's parameters: ```text export class ScopedGoalService extends GatewayService { @@ -72,28 +72,28 @@ export class ScopedGoalService extends GatewayService { super(ctx, 'goals') } - @RemoteContext('agent', 'create') + @RemoteScope('agent', 'create') remoteExportCreate(request: CreateGoalRequest): Promise { // Runs against the goals service resolved from the Agent Context. } } ``` -An endpoint selects exactly one invocation mode. A flow that needs an explicit `Agent` parameter uses `@Remote`. A flow that first switches to an Agent Context and then resolves a scoped receiver uses `@RemoteContext('agent')`. TypeRT does not infer either mode from the method body or from a missing parameter. +An endpoint selects exactly one invocation mode. A flow that needs an explicit `Agent` parameter uses `@Remote`. A flow that first switches to an Agent Context and then resolves a scoped receiver uses `@RemoteScope('agent')`. TypeRT does not infer either mode from the method body or from a missing parameter. -Business packages depend only on the lightweight `@deepseek-ai/dsh-type-meta`. It provides `GatewayService` and declaration protocols for decorators, the binding fallback, lookup, Remote Context, and descriptors, without depending on the TypeScript compiler, Zod, HTTP, or the Client runtime. +Business packages depend only on the lightweight `@deepseek-ai/dsh-type-meta`. It provides `GatewayService` and declaration protocols for decorators, the binding fallback, lookup, Remote Scope, and descriptors, without depending on the TypeScript compiler, Zod, HTTP, or the Client runtime. A method that cooperatively supports cancellation declares `signal: AbortSignal` as its final Host parameter. This reserved parameter is not a business value, lookup, or JSON field. The generated consumer method exposes it as a final optional parameter so ordinary calls remain unchanged while callers that own cancellation can pass a signal. ## Decorators and the explicit Gateway facet -A decorator only states that a method participates in the Remote contract. It performs no runtime type reflection and injects no hidden symbol into a Service constructor. The arguments to `@Remote('create')` and `@RemoteContext('agent', 'create')` are external method names; the decorated member may be the business method itself or an adapter such as `remoteExportCreate`. The member name becomes the external method name only when no alias is provided. Inheriting `GatewayService` is the normal explicit declaration that a Service has joined the Gateway; its public readonly `typertGateway` field keeps the binding visible on the runtime instance. +A decorator only states that a method participates in the Remote contract. It performs no runtime type reflection and injects no hidden symbol into a Service constructor. The arguments to `@Remote('create')` and `@RemoteScope('agent', 'create')` are external method names; the decorated member may be the business method itself or an adapter such as `remoteExportCreate`. The member name becomes the external method name only when no alias is provided. Inheriting `GatewayService` is the normal explicit declaration that a Service has joined the Gateway; its public readonly `typertGateway` field keeps the binding visible on the runtime instance. In SRC mode, the decorator may record the prototype, method name, and invocation mode in a `WeakMap` internal to `dsh-type-meta`. It writes no custom properties to a Service instance, prototype, constructor, or method function. In LIB mode, the TypeRT compiler performs strict method discovery, type resolution, and descriptor generation. It accepts a literal service key in `GatewayService`'s direct `super()` call or the explicit binding fallback; generation neither rewrites business source nor injects hidden registration metadata. -## Lookup and Remote Context registration +## Lookup and Remote Scope registration The Gateway has no built-in branches for Agent, Session, or other business objects. Each object-owning package provides both a static declaration and a runtime provider: @@ -115,7 +115,7 @@ The static declaration tells TypeRT that `Agent` corresponds to `SessionId` on t Lookup objects such as Agent and Session may each occupy only one top-level parameter position. An ordinary JSON request may be passed as another complete parameter, but this design does not support `request.agent`, object destructuring, arrays of objects, nested lookups, or searching arbitrary complex structures for IDs. -Remote Context uses a separate merge-extensible map and provider. The Agent package registers an `agent` Context provider that locates the Agent Context from its wire identity and resolves the Service key named by the descriptor from that Context. The Gateway does not know the internal structure of an Agent Context. +Remote Scope uses a separate merge-extensible map and Context provider. The Agent package registers an `agent` provider that locates the Agent Context from its wire identity and resolves the Service key named by the descriptor from that Context. The Gateway does not know the internal structure of an Agent Context. The Client also registers an `agent` Context binder. The binder only retrieves a `SessionId` from the Context in which a call occurs; it neither enumerates Scopes nor copies methods into each one. A Cordis Service tracker automatically rebinds a scoped namespace to the current Agent Context. @@ -267,7 +267,7 @@ interface TypeRTRemoteNamespaceMap { goals: TypeRTRemoteNamespace$676f616c73 } -interface TypeRTRemoteContextMap { +interface TypeRTRemoteScopeMap { 'agent:goals/create': ( request: CreateGoalRequest, signal?: AbortSignal, @@ -277,14 +277,14 @@ interface TypeRTRemoteContextMap { `TypeRTRemoteMap` preserves canonical endpoint signatures for protocol typing and reflection. The root Remote type reads `TypeRTRemoteNamespaceMap` directly instead of deriving methods indirectly through a key-remapped mapped type; the TypeScript Language Service cannot reliably navigate such indirect properties through a declaration map. A namespace interface name encodes the namespace's UTF-8 bytes as hexadecimal, so `goals` deterministically becomes `TypeRTRemoteNamespace$676f616c73`. Different packages generate the same interface name for the same namespace and use module augmentation to merge their methods, while `TypeRTRemoteNamespaceMap.goals` always refers to that one type. -TypeRT projects `TypeRTRemoteContextMap` onto a dedicated Scope type according to its Context key. The final programming interface remains: +TypeRT projects `TypeRTRemoteScopeMap` onto a dedicated Scope type according to its Context key. The final programming interface remains: ```text ctx.remote.goals.create(agentId, request) agentCtx.remote.goals.create(request) ``` -The Agent Scope supplies its own `SessionId` automatically. A `@Remote` method with an `agent` lookup can therefore generate both root and scoped consumer signatures. A `@RemoteContext('agent')` method also omits a separate Context identity, but generates only the scoped signature. The root `Context` exposes direct namespaces through `ctx.remote`, while `AgentContext.remote` intersects that direct surface with the scoped surface. A future TUI must preserve the same distinction. +The Agent Scope supplies its own `SessionId` automatically. A `@Remote` method with an `agent` lookup can therefore generate both root and scoped consumer signatures. A `@RemoteScope('agent')` method also omits a separate Scope identity, but generates only the scoped signature. The root `Context` exposes direct namespaces through `ctx.remote`, while `AgentContext.remote` intersects that direct surface with the scoped surface. A future TUI must preserve the same distinction. `TypeRTClientRemote` remains platform-independent, and the Browser Client exposes it as `ctx.remote`. If a future TUI reuses this type, it must likewise access it through a dedicated Remote object and Agent Scope rather than treating the Host `Context` as a broader Service collection. Public Service methods without Remote markers do not enter the Remote maps. @@ -313,7 +313,7 @@ Client business packages depend only on `@deepseek-ai/dsh-api-remotes/client`, n The Client Remote Service materializes each `@Remote` descriptor as a real function on a `remote.` child Service. The function constructs named `args` in descriptor parameter order, applies the Client's strict codec, and then calls `ctx.connection.rpc.call('/api', endpoint, { args }, signal)`. For a cancellation-aware descriptor, the generated function accepts a final optional signal and combines it with the contribution mount lifetime; unmounting therefore cancels every in-flight carrier call, while a caller can cancel one call independently. -Neither a direct descriptor with `scope` nor a `@RemoteContext` descriptor copies functions into every Agent Scope. The Client Remote Service creates one Cordis child Service per namespace, registered as `remote.`, and materializes direct and scoped variants on it. Accessing a method through `agentCtx.remote.goals` captures the current Agent Context before returning the callable handle. The method then asks the corresponding Context binder for identity from that Context. A direct scoped projection substitutes this identity at the lookup position named by `scope.wire`; a Context descriptor writes the identity into the receiver's separate wire field. Both issue the same kind of `/api` call. +Neither a direct descriptor with `scope` nor a `@RemoteScope` descriptor copies functions into every Agent Scope. The Client Remote Service creates one Cordis child Service per namespace, registered as `remote.`, and materializes direct and scoped variants on it. Accessing a method through `agentCtx.remote.goals` captures the current Agent Context before returning the callable handle. The method then asks the corresponding Context binder for identity from that Context. A direct scoped projection substitutes this identity at the lookup position named by `scope.wire`; a Remote Scope descriptor writes the identity into the receiver's separate wire field. Both issue the same kind of `/api` call. ```text root ctx.remote.goals.create(agentId, request) @@ -327,7 +327,7 @@ agentCtx.remote.goals.create(request) → ctx.connection.rpc.call('/api', 'goals/create', { args }) ``` -The root `Context` merges only the direct `TypeRTClientRemote` surface. `AgentContext` replaces that property with the intersection of `TypeRTClientRemote` and `TypeRTRemoteContextApi<'agent'>`, so scoped-only methods remain unavailable from root code. If a caller bypasses the type system and dynamically calls a scoped-only method from Root, the binder reports an explicit error. If the Client already has a Cordis service named `remote.`, or two contributions claim the same namespace and method incompatibly, mounting fails instead of overwriting the existing service. +The root `Context` merges only the direct `TypeRTClientRemote` surface. `AgentContext` replaces that property with the intersection of `TypeRTClientRemote` and `TypeRTRemoteScopeApi<'agent'>`, so scoped-only methods remain unavailable from root code. If a caller bypasses the type system and dynamically calls a scoped-only method from Root, the binder reports an explicit error. If the Client already has a Cordis service named `remote.`, or two contributions claim the same namespace and method incompatibly, mounting fails instead of overwriting the existing service. Generated Remote JS contains only descriptors, symbol keys, and codecs; it does not bundle Host Service implementations. The Client Remote Service creates real functions from that data, so the runtime does not depend on a JavaScript Proxy. A Proxy remains an implementation option but is not a source of types or reflection. @@ -337,7 +337,7 @@ Remote API is a consumer capability, not a synonym for Browser API. The shipped Remote DTS, Remote JS, `TypeRTClientRemote`, `InvocationDescriptor`, the Remote RPC data protocol, and Context binders must not depend on the DOM, Browser module loaders, or HTTP. Through Connection, the Browser Client encodes descriptor-materialized methods as `/api` RPC calls. -A future TUI can join the same call abstraction without changing business decorators, Remote maps, or the shape of API calls. The TUI-visible API must still be generated exclusively from `@Remote` and `@RemoteContext`; sharing a process with the Host must not allow it to bypass Remote restrictions and expose Service methods directly. +A future TUI can join the same call abstraction without changing business decorators, Remote maps, or the shape of API calls. The TUI-visible API must still be generated exclusively from `@Remote` and `@RemoteScope`; sharing a process with the Host must not allow it to bypass Remote restrictions and expose Service methods directly. TUI runtime mounting, carriers, Agent Scope association, and SRC startup wiring are outside this phase. @@ -345,7 +345,7 @@ The Web already depends on build artifacts such as `lib/client.js`, so it requir ## SRC and LIB operating modes -SRC supports local source startup. The `WeakMap` records created by `@Remote` and `@RemoteContext()` provide method names and invocation modes. At runtime, the system reads ordered parameter names from the JavaScript function signature and combines them with registered lookup/Context providers to produce a permissive descriptor. +SRC supports local source startup. The `WeakMap` records created by `@Remote` and `@RemoteScope()` provide method names and invocation modes. At runtime, the system reads ordered parameter names from the JavaScript function signature and combines them with registered lookup/Context providers to produce a permissive descriptor. For example, `@Remote('create') remoteExportCreate(agent, request, signal)` resolves to the external method `create`, implementation member `remoteExportCreate`, two top-level business parameters, and one cancellation injection point. Lookup registration rewrites `agent` to the wire field `agentId`, `request` is passed as a same-named JSON parameter, and the final `signal` stays outside the payload. SRC does not start a `ts.Program`, use a preload or loader hook, generate or rewrite source, or inspect the internal structure of an ordinary JSON object. @@ -365,7 +365,7 @@ Invocation resolves the descriptor, receiver, lookup providers, and Context prov An ordinary `@Remote` call retains the original Service instance as receiver. After lookups succeed, the Gateway calls the member identified by `implementation ?? method` with parameters in descriptor order, followed by the carrier signal when the descriptor declares cancellation. -A `@RemoteContext('agent')` call first asks the Agent Context provider to resolve the wire identity, then reads the descriptor's service key from that Context and invokes the scoped receiver. The business method receives neither a hidden Context parameter nor an Agent ID. +A `@RemoteScope('agent')` call first asks the Agent Context provider to resolve the wire identity, then reads the descriptor's service key from that Context and invokes the scoped receiver. The business method receives neither a hidden Context parameter nor an Agent ID. ```text ctx.typertGateway.invoke({ namespace, method, args, signal }) @@ -448,7 +448,7 @@ The Gateway registers only its ownership matcher and RPC handler with Connection ## Package boundaries -- `@deepseek-ai/dsh-type-meta`: lightweight protocols for decorators, bindings, lookup, Remote Context, and descriptors. +- `@deepseek-ai/dsh-type-meta`: lightweight protocols for decorators, bindings, lookup, Remote Scope, and descriptors. - TypeRT generator: analyzes Host/Client Programs, generates local faces and Remote consumer projections, and emits canonical symbol/Zod information. - TypeRT runtime: separately stores the current environment's local reflection and imported Remote contributions. - `@deepseek-ai/dsh-api-gateway`: its default entry associates Host definitions with Services, claims Remote endpoints, performs lookup, resolves Context receivers, invokes methods, encodes results, and registers an `/api` interceptor with Connection; its `/client` entry mounts Remote contributions, creates strict Remote namespace Services and methods, and delegates calls to `ctx.connection.rpc`. The entries share the Remote protocol but do not import each other's Cordis interface merges. @@ -460,7 +460,7 @@ The Gateway registers only its ownership matcher and RPC handler with Connection ## Shipped scope and deferred work -The shipped vertical path is `@deepseek-ai/dsh-goal/remote → Browser Client Remote → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`. The same direct descriptor with an Agent lookup supports both `ctx.remote.goals.create(agentId, request)` and `agentCtx.remote.goals.create(request)`. Ordinary cold sessions are resumed through `agentFor()` during lookup, while subagent-owned identities retain the existing `agent-busy` fence; `@RemoteContext('agent')` remains the distinct scoped-receiver mode. +The shipped vertical path is `@deepseek-ai/dsh-goal/remote → Browser Client Remote → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`. The same direct descriptor with an Agent lookup supports both `ctx.remote.goals.create(agentId, request)` and `agentCtx.remote.goals.create(request)`. Ordinary cold sessions are resumed through `agentFor()` during lookup, while subagent-owned identities retain the existing `agent-busy` fence; `@RemoteScope('agent')` remains the distinct scoped-receiver mode. Connection supplies the shared-channel interceptor and current HTTP carrier mapping. WebSocket migration, the TUI runtime and carrier, TUI Agent Scope wiring, Permission/Approval state machines, Session event streams, call authorization, retries, idempotency, and cross-version protocol compatibility remain outside this decision. diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index f1b7e5f9c6..0ce431b7cb 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -16,7 +16,7 @@ Host 与 Browser Client 使用独立的 TypeScript Program,因为两边会以 ## 决策 -业务 Service 继承 `GatewayService`,并通过 `@Remote` 或 `@RemoteContext()` 声明可调用方法;已有其他基类的 Service 可以改用 `bindTypeRTGateway()` 暴露同一绑定。TypeRT 从 Host Program 生成 Host 本地反射产物和平台无关的 Remote 消费端投影;Client Program 继续独立生成自己的本地反射产物。 +业务 Service 继承 `GatewayService`,并通过 `@Remote` 或 `@RemoteScope()` 声明可调用方法;已有其他基类的 Service 可以改用 `bindTypeRTGateway()` 暴露同一绑定。TypeRT 从 Host Program 生成 Host 本地反射产物和平台无关的 Remote 消费端投影;Client Program 继续独立生成自己的本地反射产物。 Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只暴露被 Remote decorator 标记的方法,并引用业务包唯一的公共类型符号;`.d.ts.map` 把消费端 API 方法导航回 Host 业务方法实现;`.js` 携带同一契约的 endpoint、参数、Context 和 Zod 信息。Browser Client 在 assembly 层把需要的 Remote JS 贡献集中挂到 Client Remote Service;该投影和 Remote 抽象保持平台无关,以便未来 TUI 复用。 @@ -64,7 +64,7 @@ export class GoalService extends GatewayService { `goals` 是传给 `super()` 的明确 Cordis service key,并默认作为 wire namespace。只有协议 namespace 确实需要与 service key 不同时,才通过第三个参数传入 `namespace` 选项。 -需要在某类隔离 Context 中查找 Service receiver 时使用 `@RemoteContext()`。Context identity 不进入业务方法参数: +需要在某类隔离 Context 中查找 Service receiver 时使用 `@RemoteScope()`。Scope identity 不进入业务方法参数: ```text export class ScopedGoalService extends GatewayService { @@ -72,28 +72,28 @@ export class ScopedGoalService extends GatewayService { super(ctx, 'goals') } - @RemoteContext('agent', 'create') + @RemoteScope('agent', 'create') remoteExportCreate(request: CreateGoalRequest): Promise { // Runs against the goals service resolved from the Agent Context. } } ``` -同一个 endpoint 只能选择一种调用模式。需要显式 `Agent` 参数的流程使用 `@Remote`;需要切换到 Agent Context 再解析 scoped receiver 的流程使用 `@RemoteContext('agent')`,两者不会由 TypeRT 根据方法体或参数缺失自动猜测。 +同一个 endpoint 只能选择一种调用模式。需要显式 `Agent` 参数的流程使用 `@Remote`;需要切换到 Agent Context 再解析 scoped receiver 的流程使用 `@RemoteScope('agent')`,两者不会由 TypeRT 根据方法体或参数缺失自动猜测。 -业务包只依赖轻量的 `@deepseek-ai/dsh-type-meta`。它提供 `GatewayService`,以及 decorator、binding 回退、lookup、Remote Context 和 descriptor 的声明协议,不依赖 TypeScript compiler、Zod、HTTP 或 Client runtime。 +业务包只依赖轻量的 `@deepseek-ai/dsh-type-meta`。它提供 `GatewayService`,以及 decorator、binding 回退、lookup、Remote Scope 和 descriptor 的声明协议,不依赖 TypeScript compiler、Zod、HTTP 或 Client runtime。 支持协作式取消的方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。这个保留参数不是业务值、lookup 或 JSON 字段。生成的消费方方法将其暴露为最后一个可选参数,因此普通调用保持不变,而拥有取消控制权的调用方可以传入 signal。 ## Decorator 与显式 Gateway facet -Decorator 只表达“该方法参与 Remote 契约”,不负责运行时类型反射,也不向 Service constructor 注入隐藏 symbol。`@Remote('create')` 和 `@RemoteContext('agent', 'create')` 的参数是外部方法名;被装饰成员既可以是业务方法本身,也可以是 `remoteExportCreate` 这样的适配器。未给别名时才使用成员名作为外部方法名。继承 `GatewayService` 是 Service 加入 Gateway 的常规显式声明;其 public readonly `typertGateway` 字段使运行时实例上的绑定保持可见。 +Decorator 只表达“该方法参与 Remote 契约”,不负责运行时类型反射,也不向 Service constructor 注入隐藏 symbol。`@Remote('create')` 和 `@RemoteScope('agent', 'create')` 的参数是外部方法名;被装饰成员既可以是业务方法本身,也可以是 `remoteExportCreate` 这样的适配器。未给别名时才使用成员名作为外部方法名。继承 `GatewayService` 是 Service 加入 Gateway 的常规显式声明;其 public readonly `typertGateway` 字段使运行时实例上的绑定保持可见。 SRC 运行时允许 decorator 在 `dsh-type-meta` 内部的 `WeakMap` 记录 prototype、方法名和调用模式。它不向 Service 实例、prototype、constructor 或方法函数写入自定义属性。 LIB 的严格方法发现、类型解析和 descriptor 生成由 TypeRT compiler 完成。它接受 `GatewayService` 直接 `super()` 调用中的字面量 service key,或显式 binding 回退;生成过程不改写业务源码,也不注入隐藏注册元数据。 -## Lookup 与 Remote Context 注册 +## Lookup 与 Remote Scope 注册 Gateway 不内置 Agent、Session 或其他业务对象分支。对象所属包同时提供静态声明和运行时 provider: @@ -115,7 +115,7 @@ ctx.typert.lookups.register('agent', { Agent、Session 等 lookup 对象只能各自占据一个顶层参数位置。普通 JSON request 可以作为另一个完整参数传入,但本设计不支持 `request.agent`、对象解构、对象数组、嵌套 lookup 或从任意复杂结构中搜索 ID。 -Remote Context 使用独立的 merge-extensible map 和 provider。Agent 包注册 `agent` Context provider,负责用 wire identity 找到 Agent Context,并从该 Context 解析 descriptor 指定的 service key;Gateway 不知道 Agent Context 的内部结构。 +Remote Scope 使用独立的 merge-extensible map 和 Context provider。Agent 包注册 `agent` provider,负责用 wire identity 找到 Agent Context,并从该 Context 解析 descriptor 指定的 service key;Gateway 不知道 Agent Context 的内部结构。 Client 侧也注册 `agent` Context binder。binder 只负责从一次调用所在的 Context 取得 `SessionId`;它不枚举 Scope,也不逐个复制方法。scoped namespace 由 Cordis Service tracker 自动 rebind 到当前 Agent Context。 @@ -267,7 +267,7 @@ interface TypeRTRemoteNamespaceMap { goals: TypeRTRemoteNamespace$676f616c73 } -interface TypeRTRemoteContextMap { +interface TypeRTRemoteScopeMap { 'agent:goals/create': ( request: CreateGoalRequest, signal?: AbortSignal, @@ -277,14 +277,14 @@ interface TypeRTRemoteContextMap { `TypeRTRemoteMap` 保留规范 endpoint 签名,供协议类型和反射使用。根 Remote 类型直接读取 `TypeRTRemoteNamespaceMap`,不通过 key-remapped mapped type 间接推导方法;TypeScript Language Service 无法把这种间接属性稳定导航到 declaration map。namespace interface 名由 namespace 的 UTF-8 bytes 编成 hex,`goals` 因而稳定得到 `TypeRTRemoteNamespace$676f616c73`。不同 package 对同一 namespace 生成同名 interface,依靠 module augmentation 合并各自方法,且 `TypeRTRemoteNamespaceMap.goals` 始终引用同一类型。 -TypeRT 把 `TypeRTRemoteContextMap` 按 Context key 投影到专用 Scope 类型。最终编程界面保持: +TypeRT 把 `TypeRTRemoteScopeMap` 按 Context key 投影到专用 Scope 类型。最终编程界面保持: ```text ctx.remote.goals.create(agentId, request) agentCtx.remote.goals.create(request) ``` -Agent Scope 自动提供自己的 `SessionId`。因此带 `agent` lookup 的 `@Remote` 方法可以同时生成 root 和 scoped 两种消费端签名;`@RemoteContext('agent')` 方法也省略独立的 Context identity,但只生成 scoped 签名。根 `Context` 通过 `ctx.remote` 暴露 direct namespace,`AgentContext.remote` 则把该 direct surface 与 scoped surface 取交集。未来 TUI 复用时必须维持相同区分。 +Agent Scope 自动提供自己的 `SessionId`。因此带 `agent` lookup 的 `@Remote` 方法可以同时生成 root 和 scoped 两种消费端签名;`@RemoteScope('agent')` 方法也省略独立的 Scope identity,但只生成 scoped 签名。根 `Context` 通过 `ctx.remote` 暴露 direct namespace,`AgentContext.remote` 则把该 direct surface 与 scoped surface 取交集。未来 TUI 复用时必须维持相同区分。 `TypeRTClientRemote` 保持平台无关,Browser Client 通过 `ctx.remote` 暴露它。未来 TUI 若复用该类型,也必须通过专用 Remote 对象和 Agent Scope 使用它,不能把 Host `Context` 当成更宽的 Service 集合;未标记的 public Service 方法不会进入 Remote maps。 @@ -313,7 +313,7 @@ Client 业务包只引用 `@deepseek-ai/dsh-api-remotes/client`,不直接依 Client Remote Service 把 `@Remote` descriptor 实体化为 `remote.` 子 Service 上的真实函数。函数按 descriptor 的位置参数顺序构造具名 `args`,执行 Client strict codec,然后调用 `ctx.connection.rpc.call('/api', endpoint, { args }, signal)`。对于支持取消的 descriptor,生成的函数接受最后一个可选 signal,并将其与 contribution 的挂载生命周期合并;因此卸载会取消所有正在进行的 carrier 调用,而调用方也可以单独取消一次调用。 -带 `scope` 的 direct descriptor 和 `@RemoteContext` descriptor 都不为每个 Agent Scope 复制函数。Client Remote Service 为每个 namespace 创建一个注册为 `remote.` 的 Cordis 子 Service,并在其上实体化 direct 与 scoped 变体。通过 `agentCtx.remote.goals` 取得方法时,accessor 会在返回可调用句柄前捕获当前 Agent Context。方法再通过对应 Context binder 从该 Context 取得 identity。direct scoped 投影用 identity 替代 `scope.wire` 指定的 lookup 位置,Context descriptor 则把 identity 写入 receiver 的独立 wire 字段;两者都发起同一种 `/api` 调用。 +带 `scope` 的 direct descriptor 和 `@RemoteScope` descriptor 都不为每个 Agent Scope 复制函数。Client Remote Service 为每个 namespace 创建一个注册为 `remote.` 的 Cordis 子 Service,并在其上实体化 direct 与 scoped 变体。通过 `agentCtx.remote.goals` 取得方法时,accessor 会在返回可调用句柄前捕获当前 Agent Context。方法再通过对应 Context binder 从该 Context 取得 identity。direct scoped 投影用 identity 替代 `scope.wire` 指定的 lookup 位置,Remote Scope descriptor 则把 identity 写入 receiver 的独立 wire 字段;两者都发起同一种 `/api` 调用。 ```text root ctx.remote.goals.create(agentId, request) @@ -327,7 +327,7 @@ agentCtx.remote.goals.create(request) → ctx.connection.rpc.call('/api', 'goals/create', { args }) ``` -根 `Context` 只 merge direct `TypeRTClientRemote` surface;`AgentContext` 把该属性替换为 `TypeRTClientRemote` 与 `TypeRTRemoteContextApi<'agent'>` 的交叉,因而 scoped-only 方法不会暴露给 root 代码。若调用方绕过类型从 Root 动态调用 scoped-only 方法,binder 明确报错。若 Client 已有名为 `remote.` 的 Cordis service,或两个 contribution 冲突占用同一 namespace/method,mount 直接失败,不覆盖现有服务。 +根 `Context` 只 merge direct `TypeRTClientRemote` surface;`AgentContext` 把该属性替换为 `TypeRTClientRemote` 与 `TypeRTRemoteScopeApi<'agent'>` 的交叉,因而 scoped-only 方法不会暴露给 root 代码。若调用方绕过类型从 Root 动态调用 scoped-only 方法,binder 明确报错。若 Client 已有名为 `remote.` 的 Cordis service,或两个 contribution 冲突占用同一 namespace/method,mount 直接失败,不覆盖现有服务。 生成的 Remote JS 只包含 descriptor、symbol key 和 codec,不打包 Host Service 实现。Client Remote Service 据此创建真实函数,因此运行时不依赖 JavaScript Proxy;Proxy 可以作为实现选择,但不会成为类型或反射来源。 @@ -337,7 +337,7 @@ Remote API 是消费端能力,不等同于 Browser API。已交付的运行时 Remote DTS、Remote JS、`TypeRTClientRemote`、`InvocationDescriptor`、Remote RPC 数据协议和 Context binder 不得依赖 DOM、Browser module loader 或 HTTP。Browser Client 通过 Connection 把 descriptor 实体化的方法编码为 `/api` RPC 调用。 -未来 TUI 可以在不改变业务 decorator、Remote maps 和 API 调用形状的前提下接入同一调用抽象。届时 TUI 可见的 API 仍只能由 `@Remote` 和 `@RemoteContext` 生成,不能因为它与 Host 同进程就绕过 Remote 限制直接暴露 Service 方法。 +未来 TUI 可以在不改变业务 decorator、Remote maps 和 API 调用形状的前提下接入同一调用抽象。届时 TUI 可见的 API 仍只能由 `@Remote` 和 `@RemoteScope` 生成,不能因为它与 Host 同进程就绕过 Remote 限制直接暴露 Service 方法。 TUI 的 runtime 挂载、carrier、Agent Scope 关联和 SRC 启动接线均不属于本期实现。 @@ -345,7 +345,7 @@ Web 本身依赖 `lib/client.js` 等构建产物,因此启动 Web 前要求完 ## SRC 与 LIB 运行模式 -SRC 面向本地源码启动。`@Remote` 和 `@RemoteContext()` 的 WeakMap 记录给出方法名和调用模式,运行时从 JavaScript 函数签名读取顺序参数名,并结合已注册 lookup/Context provider 生成弱 descriptor。 +SRC 面向本地源码启动。`@Remote` 和 `@RemoteScope()` 的 WeakMap 记录给出方法名和调用模式,运行时从 JavaScript 函数签名读取顺序参数名,并结合已注册 lookup/Context provider 生成弱 descriptor。 例如 `@Remote('create') remoteExportCreate(agent, request, signal)` 解析为外部方法 `create`、实现成员 `remoteExportCreate`、两个顶层业务参数和一个取消注入点;lookup 注册把 `agent` 改写为 wire 字段 `agentId`,`request` 按同名 JSON 参数传递,最后一个 `signal` 则留在 payload 之外。SRC 不启动 `ts.Program`,不使用 preload、loader hook、源码生成或模块改写,也不检查普通 JSON 对象的内部结构。 @@ -365,7 +365,7 @@ Host Gateway 向 Connection 注册一个 `/api` interceptor,不维护第二份 普通 `@Remote` 调用保留原始 Service 实例作为 receiver。lookup 成功后,Gateway 按 descriptor 的参数顺序调用 `implementation ?? method` 指定的成员;若 descriptor 声明取消,则在这些参数之后追加 carrier signal。 -`@RemoteContext('agent')` 调用先由 Agent Context provider 解析 wire identity,再从该 Context 读取 descriptor 的 service key 并调用 scoped receiver。业务方法不会收到隐藏 Context 参数或 Agent ID。 +`@RemoteScope('agent')` 调用先由 Agent Context provider 解析 wire identity,再从该 Context 读取 descriptor 的 service key 并调用 scoped receiver。业务方法不会收到隐藏 Context 参数或 Agent ID。 ```text ctx.typertGateway.invoke({ namespace, method, args, signal }) @@ -448,7 +448,7 @@ Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 H ## 包边界 -- `@deepseek-ai/dsh-type-meta`:轻量 decorator、binding、lookup、Remote Context 和 descriptor 协议。 +- `@deepseek-ai/dsh-type-meta`:轻量 decorator、binding、lookup、Remote Scope 和 descriptor 协议。 - TypeRT generator:分析 Host/Client Program,生成本地 face 和 Remote 消费端投影,并生成规范 symbol/Zod 信息。 - TypeRT runtime:分别保存当前环境的 local reflection 与导入的 Remote contribution。 - `@deepseek-ai/dsh-api-gateway`:默认入口关联 Host definition 与 Service,认领 Remote endpoint,执行 lookup、Context receiver 解析、调用和结果编码,并向 Connection 注册 `/api` interceptor;`/client` 入口挂载 Remote contribution,创建严格 Remote namespace Service 和方法,并把调用交给 `ctx.connection.rpc`。两个入口共享 Remote 协议,但不互相导入各自的 Cordis interface merge。 @@ -460,7 +460,7 @@ Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 H ## 已交付范围与后续工作 -已交付的纵向链路是 `@deepseek-ai/dsh-goal/remote → Browser Client Remote → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`。同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.remote.goals.create(agentId, request)` 与 `agentCtx.remote.goals.create(request)`。普通冷会话在 lookup 时通过 `agentFor()` 恢复,subagent-owned identity 保持既有 `agent-busy` fence;`@RemoteContext('agent')` 仍是独立的 scoped receiver 模式。 +已交付的纵向链路是 `@deepseek-ai/dsh-goal/remote → Browser Client Remote → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`。同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.remote.goals.create(agentId, request)` 与 `agentCtx.remote.goals.create(request)`。普通冷会话在 lookup 时通过 `agentFor()` 恢复,subagent-owned identity 保持既有 `agent-busy` fence;`@RemoteScope('agent')` 仍是独立的 scoped receiver 模式。 Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebSocket 迁移、TUI runtime 与 carrier、TUI Agent Scope 接线、Permission/Approval 状态机、Session 事件流、调用授权、重试、幂等及跨版本协议兼容均不属于本决策。 diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index d07272c182..2a6ae0807b 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: 90aa661cc86a4f419e173560c55511c969182990 -api-gateway.zh.md: 6fcbb562b204e71d00833042ee0632bda0217940 +api-gateway.md: ba95d429dd0c9f9f354baf0063197cea6e3ecbf8 +api-gateway.zh.md: 4e42ebea7a5db19c7df23079050b9488679a3a23 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 90aa661cc8..ba95d429dd 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -6,17 +6,17 @@ This is the current-state reference for the TypeRT API Gateway. It describes how ## Programming model -Business services use `@Remote` or `@RemoteContext` to select the methods exposed to the Client. Unmarked methods do not enter the generated Client types or runtime contributions and cannot be called through `ctx.remote`. +Business services use `@Remote` or `@RemoteScope` to select the methods exposed to the Client. Unmarked methods do not enter the generated Client types or runtime contributions and cannot be called through `ctx.remote`. `@Remote` denotes calling a Cordis service registered on the root Host Context. Complex Host objects cannot cross the wire directly; the business package must declare their association with a wire identity through `TypeRTLookupMap` and register a default resolution provider with `ctx.typert.lookups` at runtime. For example, an `Agent` parameter named `agent` in the Host signature produces an `agentId` wire field, and the Gateway resolves that id to a Host object before invoking the business method. Host composition can use `ctx.typert.lookups.configure()` to override the resolution policy for a lookup key without changing the parameter name, wire field, or canonical type symbol owned by the business package. -`@RemoteContext(key)` first resolves an identity to a scoped Context through `ctx.typert.contexts`, then obtains the service from that Context and invokes the method. It applies when the method itself depends on scoped composition and does not need to receive objects such as `Agent` explicitly. +`@RemoteScope(key)` first resolves an identity to a scoped Context through `ctx.typert.contexts`, then obtains the service from that Context and invokes the method. It applies when the method itself depends on scoped composition and does not need to receive objects such as `Agent` explicitly. Services normally extend `GatewayService` so the constructor explicitly binds the Cordis service key and default Remote namespace. A service that already has another base class can instead declare `readonly typertGateway = bindTypeRTGateway(this, serviceKey)`; both forms leave an inspectable public binding and do not depend on the compiler injecting a symbol into the constructor. ```ts import type { Agent } from '@deepseek-ai/dsh-agent' -import { GatewayService, Remote, RemoteContext } from '@deepseek-ai/dsh-type-meta' +import { GatewayService, Remote, RemoteScope } from '@deepseek-ai/dsh-type-meta' import type { Context } from 'cordis' export interface CreateGoalRequest { @@ -42,7 +42,7 @@ export class GoalService extends GatewayService { return this.create(agent, request) } - @RemoteContext('agent', 'current') + @RemoteScope('agent', 'current') currentForClient(): CreateGoalResult { return { accepted: true } } @@ -55,7 +55,7 @@ export class GoalService extends GatewayService { Remote methods may return a value synchronously or return a Promise. For cooperative cancellation, the final parameter in the Host signature must be `signal: AbortSignal` using the global type; it is recorded in the descriptor instead of entering `args`, while the generated Client method accepts an optional final `AbortSignal`. -The Client uses concrete functions on ordinary objects, not a JavaScript Proxy. Direct and scoped calls appear under `ctx.remote.` and `agentCtx.remote.`. Each namespace is a traced Cordis child Service registered as `remote.`; the Client assembly mounts contributions through `ctx.remote.$mount()`, consumers inject both `remote` and the namespace Service they call, and the namespace unloads after its last method is withdrawn. When an `@Remote` method has exactly one lookup parameter and a same-named `TypeRTContextMap` uses the same wire identity, the generated scoped signature omits that identity parameter. `@RemoteContext` generates only the scoped invocation interface. +The Client uses concrete functions on ordinary objects, not a JavaScript Proxy. Direct and scoped calls appear under `ctx.remote.` and `agentCtx.remote.`. Each namespace is a traced Cordis child Service registered as `remote.`; the Client assembly mounts contributions through `ctx.remote.$mount()`, consumers inject both `remote` and the namespace Service they call, and the namespace unloads after its last method is withdrawn. When an `@Remote` method has exactly one lookup parameter and a same-named `TypeRTContextMap` uses the same wire identity, the generated scoped signature omits that identity parameter. `@RemoteScope` generates only the scoped invocation interface. ```ts import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -101,7 +101,7 @@ Each contributing business package writes generated files to its own `lib/` dire | `typert.host.js` | Host Loader | Runtime reflection for the Host face, strict invocation descriptors, and schema registration values | | `typert.host.d.ts` | Host type system | Generated declarations for the Host face | | `typert.remote-client.js` | `api-remotes` | A mountable `TypeRTRemoteContribution` containing strict descriptors and runtime codecs | -| `typert.remote-client.d.ts` | Client type system | Declaration merges for `TypeRTRemoteNamespaceMap` and `TypeRTRemoteContextMap`, plus Client-safe type references | +| `typert.remote-client.d.ts` | Client type system | Declaration merges for `TypeRTRemoteNamespaceMap` and `TypeRTRemoteScopeMap`, plus Client-safe type references | | `typert.remote-client.d.ts.map` | Editor | Maps generated method properties back to Remote method declarations in the Host package | Business packages expose the Host Loader entry through `./typert` and the Host-for-Client entry through `./remote`. The generator also validates these package exports and published-file lists; it generates artifacts only for explicit contribution packages that provide the corresponding entry. @@ -126,7 +126,7 @@ Unloading a Client contribution removes its descriptors and concrete methods tog When the Host starts from source through `node --import tsx/esm`, it does not execute the TypeRT compiler plugin. Standard decorator initializers still record the method name and invocation mode in a module-private `WeakMap`, while `GatewayService` or `bindTypeRTGateway()` supplies the explicit service binding; the Gateway can therefore construct a weaker temporary descriptor without starting a `ts.Program`. -The SRC fallback parses simple parameter names from the live function. When a parameter name matches the `parameter` of a registered lookup, such as `agent` or `session`, it uses the lookup's `agentId` or `sessionId` wire field and resolves the object on the Host; other parameters are checked only for cycle-free, JSON-safe data with no special prototype. `@RemoteContext` directly uses the wire field of a registered Host Context provider. SRC does not read TypeScript types, generate Zod schemas, infer optional parameters, or support destructuring, default values, rest parameters, or duplicate parameter names. +The SRC fallback parses simple parameter names from the live function. When a parameter name matches the `parameter` of a registered lookup, such as `agent` or `session`, it uses the lookup's `agentId` or `sessionId` wire field and resolves the object on the Host; other parameters are checked only for cycle-free, JSON-safe data with no special prototype. `@RemoteScope` directly uses the wire field of a registered Host Context provider. SRC does not read TypeScript types, generate Zod schemas, infer optional parameters, or support destructuring, default values, rest parameters, or duplicate parameter names. SRC solves only dispatch for a Host process running from source. The Client does not discover decorators from the running Host, and the Client Remote refuses to mount SRC descriptors that lack strict codecs; its types, codecs, and Remote registration values always come from the most recently generated `lib/typert.remote-client.*` artifacts. diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index 6fcbb562b2..4e42ebea7a 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -6,17 +6,17 @@ ## 编程模型 -业务 Service 通过 `@Remote` 或 `@RemoteContext` 选择对 Client 开放的方法。未标记的方法不会进入生成的 Client 类型或运行时贡献,也不能通过 `ctx.remote` 调用。 +业务 Service 通过 `@Remote` 或 `@RemoteScope` 选择对 Client 开放的方法。未标记的方法不会进入生成的 Client 类型或运行时贡献,也不能通过 `ctx.remote` 调用。 `@Remote` 表示调用根 Host Context 中注册的 Cordis Service。复杂的 Host 对象不能直接跨 wire 传输;业务包必须通过 `TypeRTLookupMap` 声明它与 wire identity 的关联,并在运行时向 `ctx.typert.lookups` 注册默认解析提供方。例如 `Agent` 参数在 Host 签名中名为 `agent`,生成的 wire 字段为 `agentId`,Gateway 在调用业务方法前将 id 解析为 Host 对象。Host 组合可以用 `ctx.typert.lookups.configure()` 覆盖某个 lookup key 的解析策略,而不改变业务包拥有的参数名、wire 字段或规范类型 symbol。 -`@RemoteContext(key)` 表示先通过 `ctx.typert.contexts` 把 identity 解析为一个作用域 Context,再从该 Context 取得 Service 并调用方法。它适用于方法本身依赖作用域组合、而不需要显式接收 `Agent` 等对象的情形。 +`@RemoteScope(key)` 表示先通过 `ctx.typert.contexts` 把 identity 解析为一个作用域 Context,再从该 Context 取得 Service 并调用方法。它适用于方法本身依赖作用域组合、而不需要显式接收 `Agent` 等对象的情形。 Service 通常继承 `GatewayService`,让 Cordis service key 与默认 Remote namespace 在构造器中显式绑定。已有其他基类的 Service 可以改为声明 `readonly typertGateway = bindTypeRTGateway(this, serviceKey)`;两种方式都会留下可检查的公开 binding,不依赖编译器向构造函数注入 symbol。 ```ts import type { Agent } from '@deepseek-ai/dsh-agent' -import { GatewayService, Remote, RemoteContext } from '@deepseek-ai/dsh-type-meta' +import { GatewayService, Remote, RemoteScope } from '@deepseek-ai/dsh-type-meta' import type { Context } from 'cordis' export interface CreateGoalRequest { @@ -42,7 +42,7 @@ export class GoalService extends GatewayService { return this.create(agent, request) } - @RemoteContext('agent', 'current') + @RemoteScope('agent', 'current') currentForClient(): CreateGoalResult { return { accepted: true } } @@ -55,7 +55,7 @@ export class GoalService extends GatewayService { Remote 方法可以同步返回或返回 Promise。若需要协作式取消,Host 签名的最后一个参数必须是全局类型的 `signal: AbortSignal`;它记录在描述符中而不是进入 `args`,Client 生成的方法则接受最后一个可选的 `AbortSignal`。 -Client 使用普通对象上的具体函数,不使用 JavaScript Proxy。直接调用与作用域调用分别出现在 `ctx.remote.` 和 `agentCtx.remote.`。每个 namespace 都是注册为 `remote.` 的可追踪 Cordis 子 Service;Client assembly 通过 `ctx.remote.$mount()` 挂载贡献,消费方同时注入 `remote` 与所调用的 namespace Service,最后一个方法撤回后该 namespace 随即卸载。当一个 `@Remote` 方法恰好有一个 lookup 参数、且同名 `TypeRTContextMap` 使用相同 wire identity 时,生成的作用域签名会省略该 identity 参数。`@RemoteContext` 只生成作用域调用界面。 +Client 使用普通对象上的具体函数,不使用 JavaScript Proxy。直接调用与作用域调用分别出现在 `ctx.remote.` 和 `agentCtx.remote.`。每个 namespace 都是注册为 `remote.` 的可追踪 Cordis 子 Service;Client assembly 通过 `ctx.remote.$mount()` 挂载贡献,消费方同时注入 `remote` 与所调用的 namespace Service,最后一个方法撤回后该 namespace 随即卸载。当一个 `@Remote` 方法恰好有一个 lookup 参数、且同名 `TypeRTContextMap` 使用相同 wire identity 时,生成的作用域签名会省略该 identity 参数。`@RemoteScope` 只生成作用域调用界面。 ```ts import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -101,7 +101,7 @@ API Gateway 包同时拥有 Host dispatcher 与 Client Remote endpoint 两个对 | `typert.host.js` | Host Loader | Host face 的运行时反射、严格调用描述符和 schema 注册值 | | `typert.host.d.ts` | Host 类型系统 | Host face 的生成声明 | | `typert.remote-client.js` | `api-remotes` | 可挂载的 `TypeRTRemoteContribution`,包含严格描述符与运行时 codec | -| `typert.remote-client.d.ts` | Client 类型系统 | `TypeRTRemoteNamespaceMap` 与 `TypeRTRemoteContextMap` 的声明合并及 Client-safe 类型引用 | +| `typert.remote-client.d.ts` | Client 类型系统 | `TypeRTRemoteNamespaceMap` 与 `TypeRTRemoteScopeMap` 的声明合并及 Client-safe 类型引用 | | `typert.remote-client.d.ts.map` | 编辑器 | 将生成的方法属性映射回 Host 包中的 Remote 方法声明 | 业务包通过 `./typert` 暴露 Host Loader 入口,通过 `./remote` 暴露 Host-for-Client 入口。生成器同时校验这些 package export 及发布文件清单;只有具备相应入口的显式贡献包才会生成产物。 @@ -126,7 +126,7 @@ Client 卸载一个贡献时会一起移除描述符和具体方法,中止其 Host 通过 `node --import tsx/esm` 从源码启动时不会执行 TypeRT 编译插件。标准 decorator 初始化器仍会把方法名和调用模式记录到模块私有 `WeakMap`,`GatewayService` 或 `bindTypeRTGateway()` 则提供显式 service binding;Gateway 因而可以在不启动 `ts.Program` 的情况下构造一个较弱的临时描述符。 -SRC 回退从运行中函数解析简单参数名。参数名与某个已注册 lookup 的 `parameter` 相同,例如 `agent` 或 `session`,就使用其 `agentId` 或 `sessionId` wire 字段并在 Host 解析对象;其他参数只检查值是否为无循环、无特殊 prototype 的 JSON-safe 数据。`@RemoteContext` 直接使用已注册 Host Context provider 的 wire 字段。SRC 不读取 TypeScript 类型,不生成 Zod schema,不推断可选参数,也不支持解构、默认值、rest 或重复参数名。 +SRC 回退从运行中函数解析简单参数名。参数名与某个已注册 lookup 的 `parameter` 相同,例如 `agent` 或 `session`,就使用其 `agentId` 或 `sessionId` wire 字段并在 Host 解析对象;其他参数只检查值是否为无循环、无特殊 prototype 的 JSON-safe 数据。`@RemoteScope` 直接使用已注册 Host Context provider 的 wire 字段。SRC 不读取 TypeScript 类型,不生成 Zod schema,不推断可选参数,也不支持解构、默认值、rest 或重复参数名。 SRC 只解决 Host 源码进程的分发问题。Client 不会从运行中的 Host 发现 decorator,Client Remote 也拒绝挂载缺少严格 codec 的 SRC 描述符;其类型、codec 和 Remote 注册值始终来自最近一次生成的 `lib/typert.remote-client.*`。 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 933f204fa0..b66552b175 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: 37bc88c7c1cfedfbe1a93e08a4cbde833ac32372 -development.zh.md: a738e53cb3434d7930aa82107782a4c22aea1470 +development.md: b7ecab3536d739c105f11a640a07ea83a22f4398 +development.zh.md: 33ceba9f05c45c06acae7c83425a30c5e26ca433 diff --git a/docs/development.md b/docs/development.md index 37bc88c7c1..b7ecab3536 100644 --- a/docs/development.md +++ b/docs/development.md @@ -62,7 +62,7 @@ Host and client stay two aggregate programs because both sides declaration-merge Static analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md). -Business services declare callable methods on the Host with `@Remote` or `@RemoteContext`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions under `ctx.remote` and scoped `agentCtx.remote` namespaces. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order. +Business services declare callable methods on the Host with `@Remote` or `@RemoteScope`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions under `ctx.remote` and scoped `agentCtx.remote` namespaces. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order. If a relevant local check consumes built package output, build once first: diff --git a/docs/development.zh.md b/docs/development.zh.md index a738e53cb3..33ceba9f05 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -62,7 +62,7 @@ host 与 client 保持两个聚合 program,是因为两侧在相同键下以 静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。 -业务 Service 在 Host 使用 `@Remote` 或 `@RemoteContext` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并挂到 `ctx.remote` 与作用域 `agentCtx.remote` namespace。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。 +业务 Service 在 Host 使用 `@Remote` 或 `@RemoteScope` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并挂到 `ctx.remote` 与作用域 `agentCtx.remote` namespace。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。 如果相关的本地检查需要使用构建后的包产物,请先构建一次: diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index 3a9a0ba50d..3f4cd32e4d 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: e37359db71c1388667e9e61f538354711e90c0c1 -README.zh.md: 2054febb9a5423297c32b029b40a035062250aab +README.md: 0e1a03d2016b8cfbe165dbf1b0a9802290b29502 +README.zh.md: 6b5ccff2340405cc0045147239c5bd4f3eead7da diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index e37359db71..0e1a03d201 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -6,9 +6,9 @@ Two-sided TypeRT RPC endpoint for Host and Client Cordis environments. The Host ## Host service: `TypertGatewayService` (ctx key: `typertGateway`) -`ctx.typertGateway.invoke()` resolves the current descriptor and Cordis Service for each call, validates exact named arguments, resolves registered object or Context identities, invokes the public business method, and validates its result. Business Services extend `GatewayService` and mark methods with `@Remote` or `@RemoteContext` from [`dsh-type-meta`](../../typert/type-meta/README.md); `bindTypeRTGateway()` remains available when another base class owns inheritance. +`ctx.typertGateway.invoke()` resolves the current descriptor and Cordis Service for each call, validates exact named arguments, resolves registered object or Context identities, invokes the public business method, and validates its result. Business Services extend `GatewayService` and mark methods with `@Remote` or `@RemoteScope` from [`dsh-type-meta`](../../typert/type-meta/README.md); `bindTypeRTGateway()` remains available when another base class owns inheritance. -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()`; `@RemoteContext` resolves its receiver through a registered Host Context provider. 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. +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 provider. 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. diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index 2054febb9a..6b5ccff234 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -6,9 +6,9 @@ ## Host 服务:`TypertGatewayService`(ctx key:`typertGateway`) -每次调用时,`ctx.typertGateway.invoke()` 都会解析当前的描述符和 Cordis 服务,校验具名参数是否完全匹配,解析已注册的对象或 Context 身份标识,调用公开的业务方法,并校验其结果。业务服务继承 [`dsh-type-meta`](../../typert/type-meta/README.md) 的 `GatewayService`,并用 `@Remote` 或 `@RemoteContext` 标记方法;已有其他基类时仍可改用 `bindTypeRTGateway()`。 +每次调用时,`ctx.typertGateway.invoke()` 都会解析当前的描述符和 Cordis 服务,校验具名参数是否完全匹配,解析已注册的对象或 Context 身份标识,调用公开的业务方法,并校验其结果。业务服务继承 [`dsh-type-meta`](../../typert/type-meta/README.md) 的 `GatewayService`,并用 `@Remote` 或 `@RemoteScope` 标记方法;已有其他基类时仍可改用 `bindTypeRTGateway()`。 -严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用 `ctx.typert.lookups` 中当前有效的 resolver:业务包注册稳定声明与默认策略,Host 组合可用 effect-scoped `configure()` 覆盖解析行为;`@RemoteContext` 则通过已注册的 Host Context 提供方解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。 +严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用 `ctx.typert.lookups` 中当前有效的 resolver:业务包注册稳定声明与默认策略,Host 组合可用 effect-scoped `configure()` 覆盖解析行为;`@RemoteScope` 则通过已注册的 Host Context 提供方解析其接收者。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 等策略拒绝保持原错误码。 diff --git a/packages/api/gateway/tests/client.spec.ts b/packages/api/gateway/tests/client.spec.ts index 216f2359e7..1383175e73 100644 --- a/packages/api/gateway/tests/client.spec.ts +++ b/packages/api/gateway/tests/client.spec.ts @@ -5,7 +5,7 @@ import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client import type { InvocationDescriptor, TypeRTContext, - TypeRTRemoteContextApi, + TypeRTRemoteScopeApi, TypeRTRemoteNamespace, } from '@deepseek-ai/dsh-type-meta' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' @@ -24,7 +24,7 @@ declare module '@deepseek-ai/dsh-type-meta' { ) => Promise<{ readonly ref: string }> } - interface TypeRTRemoteContextMap { + interface TypeRTRemoteScopeMap { 'fixture:goals/create': ( request: { readonly objective: string }, signal?: AbortSignal, @@ -38,7 +38,7 @@ declare module '@deepseek-ai/dsh-type-meta' { } -type FixtureContext = Omit & { readonly remote: TypeRTRemoteContextApi<'fixture'> } +type FixtureContext = Omit & { readonly remote: TypeRTRemoteScopeApi<'fixture'> } const idSchema = z.string().min(1) const requestSchema = z.object({ objective: z.string().min(1) }) diff --git a/packages/api/gateway/tests/gateway.spec.ts b/packages/api/gateway/tests/gateway.spec.ts index d298116b82..4fa0ea80ad 100644 --- a/packages/api/gateway/tests/gateway.spec.ts +++ b/packages/api/gateway/tests/gateway.spec.ts @@ -8,7 +8,7 @@ import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserve import { bindTypeRTGateway, Remote, - RemoteContext, + RemoteScope, TypeRTLookupFailure, type InvocationDescriptor, type TypeRTContext, @@ -65,7 +65,7 @@ class GoalService extends Service { } } - @RemoteContext('gatewayFixture') + @RemoteScope('gatewayFixture') rename(request: { readonly title: string }): unknown { this.calls.push('rename') return { title: request.title, scope: (this.ctx as MarkedContext).fixtureScope ?? 'root' } @@ -299,7 +299,7 @@ class ContextWireService extends Service { super(ctx, 'contextWire') } - @RemoteContext('gatewayFixture') + @RemoteScope('gatewayFixture') run(agentId: string): string { return agentId } @@ -389,7 +389,7 @@ describe('TypertGatewayService', () => { expect(service.lastSignal?.aborted).toBe(false) }) - it('resolves strict Remote Context identity without adding a business argument', async () => { + it('resolves strict Remote Scope identity without adding a business argument', async () => { const { ctx, service } = await setup() const scoped = ctx.extend({ fixtureScope: 'agent-scope' }) ctx.typert.contexts.registerHost('gatewayFixture', contextProvider(scoped)) @@ -432,7 +432,7 @@ describe('TypertGatewayService', () => { expect(service.calls).toEqual([]) }) - it('derives SRC Remote Context identity and preserves the scoped Proxy receiver', async () => { + it('derives SRC Remote Scope identity and preserves the scoped Proxy receiver', async () => { const { ctx } = await setup() const scoped = ctx.extend({ fixtureScope: 'agent-src' }) ctx.typert.contexts.registerHost('gatewayFixture', contextProvider(scoped)) diff --git a/packages/client/runtime/src/client/agents/scope.ts b/packages/client/runtime/src/client/agents/scope.ts index 1154d10feb..25644d24ba 100644 --- a/packages/client/runtime/src/client/agents/scope.ts +++ b/packages/client/runtime/src/client/agents/scope.ts @@ -18,11 +18,11 @@ import { Context as CordisContext } from 'cordis' import type { Context, Fiber } from 'cordis' import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' -import type { TypeRTClientRemote, TypeRTRemoteContextApi } from '@deepseek-ai/dsh-type-meta' +import type { TypeRTClientRemote, TypeRTRemoteScopeApi } from '@deepseek-ai/dsh-type-meta' /** Client Cordis Context carrying one Agent identity and its scoped Remote namespaces. */ export type AgentContext = Omit & { - readonly remote: TypeRTClientRemote & TypeRTRemoteContextApi<'agent'> + readonly remote: TypeRTClientRemote & TypeRTRemoteScopeApi<'agent'> } /** Context tag written by {@link createScope}. */ diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index bc5024a7d8..c5d89b3726 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -1032,10 +1032,10 @@ class FaceAnalyzer { if (invocation.kind === 'context') { const context = this.contextDeclarations().get(invocation.context) if (context === undefined) { - this.fail(method, `Remote Context ${invocation.context} has no TypeRTContextMap entry`) + this.fail(method, `Remote Scope ${invocation.context} has no TypeRTContextMap entry`) } const wire = `${invocation.context}Id` - if (wires.has(wire)) this.fail(method, `Remote Context wire field ${wire} conflicts with a method parameter`) + if (wires.has(wire)) this.fail(method, `Remote Scope wire field ${wire} conflicts with a method parameter`) receiver = { kind: 'context', context: invocation.context, @@ -1200,18 +1200,18 @@ class FaceAnalyzer { } marker = { kind: 'direct', exportName } } else if (ts.isCallExpression(expression) - && this.isTypeMetaSymbol(expression.expression, 'RemoteContext')) { + && this.isTypeMetaSymbol(expression.expression, 'RemoteScope')) { if (expression.arguments.length < 1 || expression.arguments.length > 2) { - this.fail(expression, 'RemoteContext() requires a Context key and optional exported method name') + this.fail(expression, 'RemoteScope() requires a Context key and optional exported method name') } const context = stringLiteralValue(expression.arguments[0]) if (context === undefined || !isRemoteSegment(context)) { - this.fail(expression.arguments[0] ?? expression, 'RemoteContext() key must be a string literal containing only RPC endpoint segment characters') + this.fail(expression.arguments[0] ?? expression, 'RemoteScope() key must be a string literal containing only RPC endpoint segment characters') } const exportArgument = expression.arguments[1] const exportName = exportArgument === undefined ? undefined : stringLiteralValue(exportArgument) if (exportArgument !== undefined && (exportName === undefined || !isRemoteSegment(exportName))) { - this.fail(exportArgument, 'RemoteContext() name must be a string literal containing only RPC endpoint segment characters') + this.fail(exportArgument, 'RemoteScope() name must be a string literal containing only RPC endpoint segment characters') } marker = { kind: 'context', context, ...exportName === undefined ? {} : { exportName } } } else { @@ -2529,7 +2529,7 @@ function sourceFileHasSurface(sourceFile: ts.SourceFile): boolean { ? decorator.expression.expression : decorator.expression const name = expressionName(expression) - if (name === 'Remote' || name === 'RemoteContext') return true + if (name === 'Remote' || name === 'RemoteScope') return true } } } diff --git a/packages/typert/generator/src/emitter.ts b/packages/typert/generator/src/emitter.ts index bb39959606..cbed0047c3 100644 --- a/packages/typert/generator/src/emitter.ts +++ b/packages/typert/generator/src/emitter.ts @@ -376,7 +376,7 @@ export class FaceModelEmitter { lines.push(' }') } if (scoped.length > 0) { - lines.push(' interface TypeRTRemoteContextMap {') + lines.push(' interface TypeRTRemoteScopeMap {') for (const invocation of scoped) { this.pushRemoteSignature(lines, sourceMap, packageModel, invocation, referenceNames, true) } diff --git a/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts index 4aa51ec433..e84d6fd142 100644 --- a/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts +++ b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts @@ -1,4 +1,4 @@ -import { GatewayService, Remote, RemoteContext } from '@deepseek-ai/dsh-type-meta' +import { GatewayService, Remote, RemoteScope } from '@deepseek-ai/dsh-type-meta' import type { Agent } from '@fixture/domain' import type { CreateGoalRequest, @@ -19,7 +19,7 @@ export class GoalService extends GatewayService { return { ref: `${agent.id}:${request.title}` } } - @RemoteContext('agent') + @RemoteScope('agent') rename(request: RenameGoalRequest): RenameGoalResult { return { renamed: request.title.length > 0 } } diff --git a/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts b/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts index 5347a6b77e..707dc84ce9 100644 --- a/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts +++ b/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts @@ -11,7 +11,7 @@ declare module '@deepseek-ai/dsh-type-meta' { export interface TypeRTLookupMap {} export interface TypeRTContextMap {} export interface TypeRTRemoteMap {} - export interface TypeRTRemoteContextMap {} + export interface TypeRTRemoteScopeMap {} export type TypeRTRemoteNamespace = { [Endpoint in keyof TypeRTRemoteMap as Endpoint extends `${Namespace}/${infer Method}` @@ -56,7 +56,7 @@ declare module '@deepseek-ai/dsh-type-meta' { context: ClassMethodDecoratorContext Result>, ) => void - export function RemoteContext(key: Extract, exportName?: string): + export function RemoteScope(key: Extract, exportName?: string): ( method: (this: This, ...args: Args) => Result, context: ClassMethodDecoratorContext Result>, diff --git a/packages/typert/generator/tests/remote-model.spec.ts b/packages/typert/generator/tests/remote-model.spec.ts index eaaf680cc6..0e62a56bf4 100644 --- a/packages/typert/generator/tests/remote-model.spec.ts +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -288,7 +288,7 @@ export interface BoxPayload { const root = copyFixture() editFile(root, 'packages/remote/src/index.ts', source => source .replace(' @Remote\n', '') - .replace(" @RemoteContext('agent')\n", '')) + .replace(" @RemoteScope('agent')\n", '')) editFile(root, 'packages/remote/src/types.ts', source => `${source} /** @typert schema */ @@ -377,8 +377,8 @@ export interface ClientMarker { name: 'duplicate GatewayService field binding', edit: (source: string) => source .replace( - 'import { GatewayService, Remote, RemoteContext }', - 'import { GatewayService, Remote, RemoteContext, bindTypeRTGateway }', + 'import { GatewayService, Remote, RemoteScope }', + 'import { GatewayService, Remote, RemoteScope, bindTypeRTGateway }', ) .replace( 'export class GoalService extends GatewayService {', @@ -495,11 +495,11 @@ export interface ClientMarker { expect(() => analyzeRemote(root)).not.toThrow() }) - it('rejects a Remote Context without a static Context declaration', () => { + it('rejects a Remote Scope without a static Context declaration', () => { const root = copyFixture() - editFile(root, 'packages/remote/src/index.ts', source => source.replace("@RemoteContext('agent')", "@RemoteContext('missing')")) + editFile(root, 'packages/remote/src/index.ts', source => source.replace("@RemoteScope('agent')", "@RemoteScope('missing')")) - expect(() => analyzeRemote(root, false)).toThrow(/Remote Context missing has no TypeRTContextMap entry/) + expect(() => analyzeRemote(root, false)).toThrow(/Remote Scope missing has no TypeRTContextMap entry/) }) it('rejects a direct scoped projection whose Context and lookup wire symbols differ', () => { @@ -579,7 +579,7 @@ function assertRemoteConsumerTypechecks( import remote from '@fixture/remote/remote' import type { TypeRTRemoteContribution, - TypeRTRemoteContextMap, + TypeRTRemoteScopeMap, TypeRTRemoteMap, TypeRTRemoteNamespaceMap, } from '@deepseek-ai/dsh-type-meta' @@ -587,8 +587,8 @@ import type { CreateGoalResult, RenameGoalResult } from '@fixture/remote/types' const contribution: TypeRTRemoteContribution = remote declare const create: TypeRTRemoteMap['goals/create'] -declare const createScoped: TypeRTRemoteContextMap['agent:goals/create'] -declare const rename: TypeRTRemoteContextMap['agent:goals/rename'] +declare const createScoped: TypeRTRemoteScopeMap['agent:goals/create'] +declare const rename: TypeRTRemoteScopeMap['agent:goals/rename'] const created: Promise = create('agent-1', { title: 'ship' }) const cancellable: Promise = create('agent-1', { title: 'ship' }, new AbortController().signal) const createdScoped: Promise = createScoped({ title: 'ship' }) diff --git a/packages/typert/type-meta/README.i18n.yaml b/packages/typert/type-meta/README.i18n.yaml index 6c21127e54..a61602c07b 100644 --- a/packages/typert/type-meta/README.i18n.yaml +++ b/packages/typert/type-meta/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/typert/type-meta/README.md -README.md: a76169742cb78d0d19814bcd0f978c71036a5a1c -README.zh.md: 6f2d2fd6e241441fae8102c0639608e9b27b9bec +README.md: 9bd475f8973ec54756fe0e63d5b7fa485381697d +README.zh.md: 10a6309bc47001d572abb3dd3f794ebfcf6252e8 diff --git a/packages/typert/type-meta/README.md b/packages/typert/type-meta/README.md index a76169742c..9bd475f897 100644 --- a/packages/typert/type-meta/README.md +++ b/packages/typert/type-meta/README.md @@ -7,7 +7,7 @@ Compiler-independent declarations shared by business packages, generated TypeRT ## Remote declarations - `@Remote` marks a public instance method for direct invocation on its registered Cordis Service. -- `@RemoteContext(key)` marks a method whose receiver is selected from a merge-declared scoped Context kind. +- `@RemoteScope(key)` marks a method whose receiver is selected from a merge-declared scoped Context kind. - `GatewayService` binds the Cordis key passed to `super(ctx, serviceKey, options?)` to the same default wire namespace. - `bindTypeRTGateway(this, serviceKey, options?)` provides the same visible, frozen binding for a Service that cannot inherit from `GatewayService`. - `remoteMethods(service)` returns a detached declaration-order snapshot used by the Gateway's SRC fallback. @@ -18,7 +18,7 @@ Decorator initializers retain markers in a module-private `WeakMap` keyed by the ## TypeRT protocol -Business packages extend `TypeRTLookupMap` and `TypeRTContextMap` to associate Host objects or scoped Contexts with their wire identities. Generated artifacts extend `TypeRTRemoteMap`, `TypeRTRemoteContextMap`, and `TypeRTRemoteNamespaceMap` so Client imports expose only selected Remote methods. `InvocationDescriptor` is the shared runtime form consumed by the registry, Gateway, and Client API. +Business packages extend `TypeRTLookupMap` and `TypeRTContextMap` to associate Host objects or scoped Contexts with their wire identities. Generated artifacts extend `TypeRTRemoteMap`, `TypeRTRemoteScopeMap`, and `TypeRTRemoteNamespaceMap` so Client imports expose only selected Remote methods. `InvocationDescriptor` is the shared runtime form consumed by the registry, Gateway, and Client Remote. Lookup and Context packages own both sides of their contract: declaration merging supplies the static association, while runtime providers register identity resolution with `ctx.typert`. A lookup or Host Context provider supplies the stable declaration and default resolver, while Host composition may separately configure a synchronous or asynchronous resolver; policy rejections may use `TypeRTLookupFailure` to carry a failure value owned by the boundary adapter. Strict codecs carry generated schemas; `src-json` codecs identify the weaker source-launch path. diff --git a/packages/typert/type-meta/README.zh.md b/packages/typert/type-meta/README.zh.md index 6f2d2fd6e2..10a6309bc4 100644 --- a/packages/typert/type-meta/README.zh.md +++ b/packages/typert/type-meta/README.zh.md @@ -7,7 +7,7 @@ ## Remote 声明 - `@Remote` 将公开实例方法标记为可在其注册的 Cordis 服务上直接调用。 -- `@RemoteContext(key)` 标记接收者选自合并声明的作用域 Context 类型的方法。 +- `@RemoteScope(key)` 标记接收者选自合并声明的作用域 Context 类型的方法。 - `GatewayService` 将 `super(ctx, serviceKey, options?)` 接收的 Cordis key 同时绑定为默认 wire namespace。 - `bindTypeRTGateway(this, serviceKey, options?)` 为无法继承 `GatewayService` 的 Service 提供同样可见且冻结的绑定。 - `remoteMethods(service)` 返回按声明顺序排列、与内部状态分离的快照,供 Gateway 的 SRC 回退路径使用。 @@ -18,7 +18,7 @@ Host 方法通过将 `signal: AbortSignal` 声明为最后一个参数来启用 ## TypeRT 协议 -业务包扩展 `TypeRTLookupMap` 和 `TypeRTContextMap`,以关联 Host 对象或作用域 Context 与其协议身份。生成的产物扩展 `TypeRTRemoteMap`、`TypeRTRemoteContextMap` 和 `TypeRTRemoteNamespaceMap`,使 Client 导入后仅暴露选定的 Remote 方法。`InvocationDescriptor` 是供注册表、Gateway 和 Client API 使用的共享运行时形式。 +业务包扩展 `TypeRTLookupMap` 和 `TypeRTContextMap`,以关联 Host 对象或作用域 Context 与其协议身份。生成的产物扩展 `TypeRTRemoteMap`、`TypeRTRemoteScopeMap` 和 `TypeRTRemoteNamespaceMap`,使 Client 导入后仅暴露选定的 Remote 方法。`InvocationDescriptor` 是供注册表、Gateway 和 Client Remote 使用的共享运行时形式。 查找包与 Context 包同时负责其契约的两侧:声明合并提供静态关联,运行时提供方则向 `ctx.typert` 注册身份解析。lookup 或 Host Context provider 提供稳定声明与默认 resolver,Host 组合可以另行配置同步或异步 resolver;策略拒绝可用 `TypeRTLookupFailure` 携带由边界适配器拥有的失败值。严格编解码器携带生成的 schema;`src-json` 编解码器标识约束更弱的源码启动路径。 diff --git a/packages/typert/type-meta/src/index.ts b/packages/typert/type-meta/src/index.ts index 774c6d3b32..1375d7872b 100644 --- a/packages/typert/type-meta/src/index.ts +++ b/packages/typert/type-meta/src/index.ts @@ -60,9 +60,9 @@ export type { TypeRTLookupResolver, TypeRTLookupRegistry, TypeRTLookupWire, - TypeRTRemoteContextApi, - TypeRTRemoteContextMap, - TypeRTRemoteContextNamespace, + TypeRTRemoteScopeApi, + TypeRTRemoteScopeMap, + TypeRTRemoteScopeNamespace, TypeRTRemoteContribution, TypeRTRemoteMap, TypeRTRemoteNamespace, @@ -191,16 +191,16 @@ export function Remote( } /** - * Create a decorator for a method resolved from one scoped Remote Context. - * @param key - merge-declared Context key. + * Create a decorator for a method resolved from one Remote Scope. + * @param key - scope key declared through the Context map. * @param exportName - optional Remote export name; defaults to the method name. * @returns a standard method decorator that records only private module state. */ -export function RemoteContext( +export function RemoteScope( key: Extract, exportName?: string, ): RemoteMethodDecorator { - validateName('Context key', key) + validateName('Scope key', key) if (exportName !== undefined) validateName('Remote export name', exportName) return function ( _method: (this: This, ...args: Args) => Result, diff --git a/packages/typert/type-meta/src/types.ts b/packages/typert/type-meta/src/types.ts index 5e7c20cd7c..c1d6b3dcf9 100644 --- a/packages/typert/type-meta/src/types.ts +++ b/packages/typert/type-meta/src/types.ts @@ -40,7 +40,7 @@ export interface TypeRTContextMap {} export interface TypeRTRemoteMap {} /** Merge-extensible scoped Remote method signatures generated for consumers. */ -export interface TypeRTRemoteContextMap {} +export interface TypeRTRemoteScopeMap {} /** * Resolve one direct Remote namespace from the generated flat endpoint map. @@ -57,24 +57,24 @@ export type TypeRTRemoteNamespace = { * The calling Cordis Context supplies the concrete identity at runtime. * @template Namespace - wire namespace between the Context prefix and method. */ -export type TypeRTRemoteContextNamespace< +export type TypeRTRemoteScopeNamespace< Namespace extends string, ContextKey extends string = string, > = { - [Endpoint in keyof TypeRTRemoteContextMap as Endpoint extends `${ContextKey}:${Namespace}/${infer Method}` + [Endpoint in keyof TypeRTRemoteScopeMap as Endpoint extends `${ContextKey}:${Namespace}/${infer Method}` ? Method - : never]: TypeRTRemoteContextMap[Endpoint] + : never]: TypeRTRemoteScopeMap[Endpoint] } -type TypeRTRemoteContextNamespaceKey< +type TypeRTRemoteScopeNamespaceKey< ContextKey extends string, - Endpoint = keyof TypeRTRemoteContextMap, + Endpoint = keyof TypeRTRemoteScopeMap, > = Endpoint extends `${ContextKey}:${infer Namespace}/${string}` ? Namespace : never /** Generated scoped Remote namespaces available to one Context kind. */ -export type TypeRTRemoteContextApi = { - [Namespace in TypeRTRemoteContextNamespaceKey]: - TypeRTRemoteContextNamespace +export type TypeRTRemoteScopeApi = { + [Namespace in TypeRTRemoteScopeNamespaceKey]: + TypeRTRemoteScopeNamespace } /** Merge-extensible direct namespace surface generated for Client Remote services. */ @@ -227,7 +227,7 @@ export interface TypeRTLookupDefinition { readonly wireTypeSymbol: string } -/** Host resolver for one scoped Remote Context kind. */ +/** Host resolver for one scoped Remote kind. */ export interface TypeRTHostContextProvider { /** Wire field carrying the Context identity. */ readonly wire: string diff --git a/packages/typert/type-meta/tests/fixtures/source-launch.ts b/packages/typert/type-meta/tests/fixtures/source-launch.ts index b13a80796d..14eec6610d 100644 --- a/packages/typert/type-meta/tests/fixtures/source-launch.ts +++ b/packages/typert/type-meta/tests/fixtures/source-launch.ts @@ -2,7 +2,7 @@ import { Context } from 'cordis' import { GatewayService, Remote, - RemoteContext, + RemoteScope, remoteMethods, } from '@deepseek-ai/dsh-type-meta' @@ -16,7 +16,7 @@ class Goals extends GatewayService { return value } - @RemoteContext('agent') + @RemoteScope('agent') scoped(value: string): string { return value } diff --git a/packages/typert/type-meta/tests/type-meta.spec.ts b/packages/typert/type-meta/tests/type-meta.spec.ts index b84b76300c..bfe99630b9 100644 --- a/packages/typert/type-meta/tests/type-meta.spec.ts +++ b/packages/typert/type-meta/tests/type-meta.spec.ts @@ -6,7 +6,7 @@ import { bindTypeRTGateway, GatewayService, Remote, - RemoteContext, + RemoteScope, remoteMethods, type TypeRTContext, } from '@deepseek-ai/dsh-type-meta' @@ -29,7 +29,7 @@ describe('type-meta Remote declarations', () => { return value } - @RemoteContext('metaFixture') + @RemoteScope('metaFixture') scoped(value: string): string { return value } @@ -84,7 +84,7 @@ describe('type-meta Remote declarations', () => { Reflect.get(Goals.prototype, 'create') as (this: Goals, ...args: unknown[]) => unknown, methodContext('create', initializers), ) - RemoteContext('metaFixture')( + RemoteScope('metaFixture')( Reflect.get(Goals.prototype, 'scoped') as (this: Goals, ...args: unknown[]) => unknown, methodContext('scoped', initializers), ) @@ -141,7 +141,7 @@ describe('type-meta Remote declarations', () => { Reflect.get(Service.prototype, 'run') as (this: Service, ...args: unknown[]) => unknown, methodContext('run', initializers), ) - RemoteContext('metaFixture', 'inspect')( + RemoteScope('metaFixture', 'inspect')( Reflect.get(Service.prototype, 'scoped') as (this: Service, ...args: unknown[]) => unknown, methodContext('scoped', initializers), ) @@ -166,8 +166,8 @@ describe('type-meta Remote declarations', () => { expect(() => Remote('bad name')).toThrow('export name') expect(() => Remote('.')).toThrow('export name') expect(() => Remote('..')).toThrow('export name') - expect(() => RemoteContext('' as 'metaFixture')).toThrow('Context key') - expect(() => RemoteContext('metaFixture', 'bad/name')).toThrow('export name') + expect(() => RemoteScope('' as 'metaFixture')).toThrow('Scope key') + expect(() => RemoteScope('metaFixture', 'bad/name')).toThrow('export name') for (const context of [ { ...methodContext('run', []), private: true }, @@ -195,7 +195,7 @@ describe('type-meta Remote declarations', () => { Reflect.get(Service.prototype, 'run'), methodContext('run', conflicting), ) - RemoteContext('metaFixture')( + RemoteScope('metaFixture')( Reflect.get(Service.prototype, 'run'), methodContext('run', conflicting), ) From 8bbbb6fe59d71e52f0aeefcd1c95c7bd84e55f91 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:23:47 +0800 Subject: [PATCH 095/104] fix(api-gateway): compose scoped remote fixture types --- packages/api/gateway/tests/client.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/api/gateway/tests/client.spec.ts b/packages/api/gateway/tests/client.spec.ts index 1383175e73..d253c38acc 100644 --- a/packages/api/gateway/tests/client.spec.ts +++ b/packages/api/gateway/tests/client.spec.ts @@ -4,6 +4,7 @@ import { z } from 'zod' import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { InvocationDescriptor, + TypeRTClientRemote, TypeRTContext, TypeRTRemoteScopeApi, TypeRTRemoteNamespace, @@ -38,7 +39,9 @@ declare module '@deepseek-ai/dsh-type-meta' { } -type FixtureContext = Omit & { readonly remote: TypeRTRemoteScopeApi<'fixture'> } +type FixtureContext = Omit & { + readonly remote: TypeRTClientRemote & TypeRTRemoteScopeApi<'fixture'> +} const idSchema = z.string().min(1) const requestSchema = z.object({ objective: z.string().min(1) }) From 55ccfb5a48ab9d50b7953119036117c50057d650 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:44:47 +0800 Subject: [PATCH 096/104] fix(api): preserve dynamic defaults after rebase --- packages/api/remotes/src/agent-lookup.ts | 6 +++--- packages/host/apiproxy/tests/api-proxy-cold.spec.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/api/remotes/src/agent-lookup.ts b/packages/api/remotes/src/agent-lookup.ts index eb54ea9b0b..71d7a76379 100644 --- a/packages/api/remotes/src/agent-lookup.ts +++ b/packages/api/remotes/src/agent-lookup.ts @@ -20,8 +20,8 @@ export type ApiRemoteAgentResult = /** Resume configuration supplied by the owning Host composition. */ export interface ApiRemoteAgentOptions { - /** Per-Agent defaults used when a cold identity must resume. */ - readonly agentOptions?: AgentOptions + /** Read the per-Agent defaults when a cold identity must resume. */ + readonly agentOptions?: () => AgentOptions /** Host-specific Agent-scope composition completed before publication. */ readonly setup?: AgentSetup } @@ -144,7 +144,7 @@ export function createApiRemoteAgentResolver( } const handle = await ctx.agents.resume({ resumeSessionId: sessionId, - ...options.agentOptions === undefined ? {} : { agentOptions: options.agentOptions }, + ...options.agentOptions === undefined ? {} : { agentOptions: options.agentOptions() }, ...options.setup === undefined ? {} : { setup: options.setup }, }) return handle.agent diff --git a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts index e5e137f0c4..8e79c641a8 100644 --- a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts @@ -206,7 +206,7 @@ describe('Remote Agent and Session lookup policy', () => { }) const defaultAgentLookup = ctx.typert.lookups.get('agent') const defaultSessionLookup = ctx.typert.lookups.get('session') - createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) await vi.waitFor(() => { expect(ctx.typert.lookups.get('agent')).not.toBe(defaultAgentLookup) expect(ctx.typert.lookups.get('session')).not.toBe(defaultSessionLookup) @@ -250,7 +250,7 @@ describe('Remote Agent and Session lookup policy', () => { const resume = vi.spyOn(ctx.agents, 'resume') const defaultAgentLookup = ctx.typert.lookups.get('agent') const defaultSessionLookup = ctx.typert.lookups.get('session') - createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) await vi.waitFor(() => { expect(ctx.typert.lookups.get('agent')).not.toBe(defaultAgentLookup) expect(ctx.typert.lookups.get('session')).not.toBe(defaultSessionLookup) From 5c2625c26eb6affb3732be713823ec655dabf5a0 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:02:12 +0800 Subject: [PATCH 097/104] docs(typert): align client remote type mapping --- scripts/type-equiv.manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index ecb13f167c..095fa25b4f 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1552,7 +1552,7 @@ }, { "doc": "docs/core-data-structures/typert.md", - "symbol": "TypeRTClientApi", + "symbol": "TypeRTClientRemote", "source": "packages/typert/type-meta/src/types.ts" } ] From 14ea7e134d0fe90b54218b651da7c00ca4e89a1f Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:02:12 +0800 Subject: [PATCH 098/104] fix(api-remotes): await namespace assembly startup --- packages/api/remotes/src/client/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index ebd342300e..be92b02d77 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -20,7 +20,8 @@ export const inject = ['remote'] /** * Mount the Host capabilities explicitly selected for this Client assembly. * @param ctx - Client Cordis root carrying the typed API service. + * @returns disposer after every selected Remote namespace is ready. */ -export function apply(ctx: Context): Promise<() => Promise> { - return ctx.remote.$mount(goalsRemote) +export async function apply(ctx: Context): Promise<() => Promise> { + return await ctx.remote.$mount(goalsRemote) } From 8b51a1e95c4bc87d69ac5c060ec390ea571b08f4 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:11:42 +0800 Subject: [PATCH 099/104] test(api-gateway): cover namespace rollback paths --- packages/api/gateway/src/client/index.ts | 58 +++++++++-------------- packages/api/gateway/tests/client.spec.ts | 40 ++++++++++++++++ 2 files changed, 62 insertions(+), 36 deletions(-) diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index d0429339c8..e49e9e5822 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -194,7 +194,7 @@ class ClientRemoteService extends Service implements TypeRTClientRemote { throw error } return async () => { - if (!namespace.service.remove('direct', descriptor.method, token)) return + namespace.service.remove('direct', descriptor.method, token) await this.disposeNamespace(descriptor.namespace, namespace) } } @@ -212,7 +212,7 @@ class ClientRemoteService extends Service implements TypeRTClientRemote { throw error } return async () => { - if (!namespace.service.remove('scoped', descriptor.method, token)) return + namespace.service.remove('scoped', descriptor.method, token) await this.disposeNamespace(descriptor.namespace, namespace) } } @@ -390,50 +390,36 @@ class RemoteNamespaceService extends Service { let record = this.methods.get(method) const fresh = record === undefined record ??= {} - if (record[kind] !== undefined) { - throw new Error(`client api: ${kind} method ${this.namespace}/${method} is already mounted`) - } - try { - if (fresh) { - Object.defineProperty(this, method, { - configurable: true, - enumerable: true, - get: function (this: RemoteNamespaceService): (...args: unknown[]) => Promise { - const callerCtx = this.ctx - const current = this.methods.get(method) - const direct = current?.direct - const scoped = current?.scoped - return (...args: unknown[]) => { - return this.invokeRemote(direct, scoped, callerCtx, args) - } - }, - }) - this.methods.set(method, record) - } - if (kind === 'direct') record.direct = value - else record.scoped = value as ScopedMethod - } catch (error) { - if (kind === 'direct') delete record.direct - else delete record.scoped - if (fresh) { - this.methods.delete(method) - Reflect.deleteProperty(this, method) - } - throw error + if (fresh) { + Object.defineProperty(this, method, { + configurable: true, + enumerable: true, + get: function (this: RemoteNamespaceService): (...args: unknown[]) => Promise { + const callerCtx = this.ctx + const current = this.methods.get(method) + const direct = current?.direct + const scoped = current?.scoped + return (...args: unknown[]) => { + return this.invokeRemote(direct, scoped, callerCtx, args) + } + }, + }) + this.methods.set(method, record) } + if (kind === 'direct') record.direct = value + else record.scoped = value as ScopedMethod } - remove(kind: 'direct' | 'scoped', method: string, token: MountToken): boolean { + remove(kind: 'direct' | 'scoped', method: string, token: MountToken): void { const record = this.methods.get(method) const current = record?.[kind] /* v8 ignore next -- duplicate live variants are rejected before installation, so no newer token can replace this one. */ - if (record === undefined || current?.token !== token) return false + if (record === undefined || current?.token !== token) return if (kind === 'direct') delete record.direct else delete record.scoped - if (record.direct !== undefined || record.scoped !== undefined) return true + if (record.direct !== undefined || record.scoped !== undefined) return this.methods.delete(method) Reflect.deleteProperty(this, method) - return true } } diff --git a/packages/api/gateway/tests/client.spec.ts b/packages/api/gateway/tests/client.spec.ts index d253c38acc..01bb9c53b7 100644 --- a/packages/api/gateway/tests/client.spec.ts +++ b/packages/api/gateway/tests/client.spec.ts @@ -316,6 +316,32 @@ describe('Client TypeRT API', () => { await retry() }) + it('rolls back a direct projection when its scoped projection fails to install', async () => { + const ctx = await bench(vi.fn()) + const disposeContext = await ctx.remote.$mount({ + package: '@fixture/context-anchor', + descriptors: [contextDescriptor()], + }) + const namespace = ctx.get('remote.goals') as unknown as { + installScoped: (...args: unknown[]) => void + readonly create?: unknown + } + const installScoped = vi.spyOn(namespace, 'installScoped').mockImplementation(() => { + throw new Error('fixture scoped projection failure') + }) + try { + await expect(ctx.remote.$mount({ + package: '@fixture/direct-projection-failure', + descriptors: [directDescriptor()], + })).rejects.toThrow('fixture scoped projection failure') + } finally { + installScoped.mockRestore() + } + + expect(namespace.create).toBeUndefined() + await disposeContext() + }) + it('rejects weak parameter and Context codecs plus malformed scope projections', async () => { const ctx = await bench(vi.fn()) const direct = directDescriptor() @@ -406,6 +432,20 @@ describe('Client TypeRT API', () => { expect((ctx.remote as unknown as Record).goals).toBeUndefined() }) + it('rejects a method obtained from a withdrawn namespace getter', async () => { + const ctx = await bench(vi.fn()) + const dispose = await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }) + const namespace = ctx.get('remote.goals') as unknown as object + const getter = Object.getOwnPropertyDescriptor(namespace, 'create')?.get + + await dispose() + + expect(getter).toBeTypeOf('function') + const withdrawn = getter?.call(namespace) as (...args: unknown[]) => Promise + await expect(withdrawn('agent-1', { objective: 'ship' })) + .rejects.toThrow('Remote method is no longer mounted') + }) + it('preserves a __proto__ wire parameter as an own named argument', async () => { const call = vi.fn() .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } }) From 00a559bf2b97caf8f15b2e8303cbb17b061692de Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:16:29 +0800 Subject: [PATCH 100/104] test(api-gateway): assert withdrawn method failure --- packages/api/gateway/tests/client.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/api/gateway/tests/client.spec.ts b/packages/api/gateway/tests/client.spec.ts index 01bb9c53b7..2284fa662c 100644 --- a/packages/api/gateway/tests/client.spec.ts +++ b/packages/api/gateway/tests/client.spec.ts @@ -432,7 +432,7 @@ describe('Client TypeRT API', () => { expect((ctx.remote as unknown as Record).goals).toBeUndefined() }) - it('rejects a method obtained from a withdrawn namespace getter', async () => { + it('fails a method obtained from a withdrawn namespace getter', async () => { const ctx = await bench(vi.fn()) const dispose = await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }) const namespace = ctx.get('remote.goals') as unknown as object @@ -442,8 +442,8 @@ describe('Client TypeRT API', () => { expect(getter).toBeTypeOf('function') const withdrawn = getter?.call(namespace) as (...args: unknown[]) => Promise - await expect(withdrawn('agent-1', { objective: 'ship' })) - .rejects.toThrow('Remote method is no longer mounted') + expect(() => withdrawn('agent-1', { objective: 'ship' })) + .toThrow('Remote method is no longer mounted') }) it('preserves a __proto__ wire parameter as an own named argument', async () => { From ddd43ec3718eb4d97e1db072940e35073cad4d6c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:29:39 +0800 Subject: [PATCH 101/104] test(api-gateway): repair CI fixtures --- packages/api/gateway/tests/client.spec.ts | 6 +++--- .../translation-prompt-v4/request-response.expected.json | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/api/gateway/tests/client.spec.ts b/packages/api/gateway/tests/client.spec.ts index 2284fa662c..641ea81ebc 100644 --- a/packages/api/gateway/tests/client.spec.ts +++ b/packages/api/gateway/tests/client.spec.ts @@ -436,12 +436,12 @@ describe('Client TypeRT API', () => { const ctx = await bench(vi.fn()) const dispose = await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }) const namespace = ctx.get('remote.goals') as unknown as object - const getter = Object.getOwnPropertyDescriptor(namespace, 'create')?.get + const getWithdrawn = Object.getOwnPropertyDescriptor(namespace, 'create')?.get?.bind(namespace) await dispose() - expect(getter).toBeTypeOf('function') - const withdrawn = getter?.call(namespace) as (...args: unknown[]) => Promise + expect(getWithdrawn).toBeTypeOf('function') + const withdrawn = getWithdrawn?.() as (...args: unknown[]) => Promise expect(() => withdrawn('agent-1', { objective: 'ship' })) .toThrow('Remote method is no longer mounted') }) diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index 794cf18f49..91509f3267 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -16,11 +16,11 @@ }, { "role": "user", - "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThe setup tutorial takes a new contributor from prerequisites to a checked checkout. The contributor reference that follows covers repository layout, daily workflow, and CI shape. Design rationale and implementation details belong to the linked Agent Notes and scripts.\n\n## Setup tutorial\n\n### Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the Web, headless, and ACP automation demos and real-API e2e tests.\n\n### First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also configures worktree-local lefthook hooks through `scripts/install-lefthook.mjs`. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the safety and migration contract.\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nIf the wrapper rejects existing Git configuration or reports a stale lock, follow its diagnostic and the linked Agent Note rather than editing worktree metadata speculatively. After moving a checkout, rerun the wrapper to regenerate the owned path.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nSetup is complete when `pnpm run typecheck` exits successfully.\n\n## Contributor reference\n\n### TypeScript project layout\n\nThe repository typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through two no-emit aggregates.\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- 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. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md).\n\nBusiness services declare callable methods on the Host with `@Remote` or `@RemoteContext`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions and mounts concrete methods on `ctx.api` or the corresponding scoped Context. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order.\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n### Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n### Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` applies formatting-only ESLint fixes, validates the staged files with Oxlint and applies its native fixes, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n### CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n### Daily commands\n\nThe root [contributor instructions](../AGENTS.md#commands) summarize common commands, while [`package.json`](../package.json) and [scripts/run-gates.ts](../scripts/run-gates.ts) own the current script and gate inventories. Select the smallest checks that cover the changed surface. Documentation changes use `pnpm run doc-sync`; package-public behavior changes also update the owning README or JSDoc, and built-artifact checks require `pnpm run build` first.\n\n### Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n### Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n" + "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThe setup tutorial takes a new contributor from prerequisites to a checked checkout. The contributor reference that follows covers repository layout, daily workflow, and CI shape. Design rationale and implementation details belong to the linked Agent Notes and scripts.\n\n## Setup tutorial\n\n### Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the Web, headless, and ACP automation demos and real-API e2e tests.\n\n### First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also configures worktree-local lefthook hooks through `scripts/install-lefthook.mjs`. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the safety and migration contract.\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nIf the wrapper rejects existing Git configuration or reports a stale lock, follow its diagnostic and the linked Agent Note rather than editing worktree metadata speculatively. After moving a checkout, rerun the wrapper to regenerate the owned path.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nSetup is complete when `pnpm run typecheck` exits successfully.\n\n## Contributor reference\n\n### TypeScript project layout\n\nThe repository typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through two no-emit aggregates.\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- 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. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md).\n\nBusiness services declare callable methods on the Host with `@Remote` or `@RemoteScope`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions under `ctx.remote` and scoped `agentCtx.remote` namespaces. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order.\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n### Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n### Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` applies formatting-only ESLint fixes, validates the staged files with Oxlint and applies its native fixes, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n### CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n### Daily commands\n\nThe root [contributor instructions](../AGENTS.md#commands) summarize common commands, while [`package.json`](../package.json) and [scripts/run-gates.ts](../scripts/run-gates.ts) own the current script and gate inventories. Select the smallest checks that cover the changed surface. Documentation changes use `pnpm run doc-sync`; package-public behavior changes also update the owning README or JSDoc, and built-artifact checks require `pnpm run build` first.\n\n### Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n### Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n" }, { "role": "assistant", - "content": "# 开发指南\n\n[English](development.md) | 中文\n\n搭建教程引导新贡献者从准备前置条件开始,直到检出通过检查。后面的贡献者参考介绍仓库布局、日常工作流和 CI 形态。设计依据与实现细节属于链接的 Agent Note 和脚本。\n\n## 搭建教程\n\n### 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 Web、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n### 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程还会通过 `scripts/install-lefthook.mjs` 配置 worktree 本地的 lefthook 钩子。其安全与迁移契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 负责。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n如果包装脚本拒绝现有 Git 配置或报告陈旧锁,请遵循其诊断和所链接的 Agent Note,不要凭猜测编辑 worktree 元数据。移动检出目录后,请重新运行包装脚本以重新生成自有路径。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n`pnpm run typecheck` 成功退出即表示搭建完成。\n\n## 贡献者参考\n\n### TypeScript 项目布局\n\n仓库类型检查会执行全仓 `tsc -b` 图:它会发射每个 package/vendor 的 `lib/types`,并通过两个 no-emit 聚合检查示例、测试和脚本。\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。\n\n业务 Service 在 Host 使用 `@Remote` 或 `@RemoteContext` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并将具体方法挂载到 `ctx.api` 或对应的作用域 Context。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n### 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n### Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 应用仅用于格式化的 ESLint 修复,使用 Oxlint 验证暂存文件并应用其原生修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n### CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n### 日常命令\n\n根目录的[贡献者说明](../AGENTS.md#commands)概述常用命令,[`package.json`](../package.json) 与 [scripts/run-gates.ts](../scripts/run-gates.ts) 则负责当前脚本和门禁清单。请选择覆盖变更表面的最小检查集。文档变更使用 `pnpm run doc-sync`;package 公开行为变更还需更新所属 README 或 JSDoc,而基于构建产物的检查需要先运行 `pnpm run build`。\n\n### 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n### 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n" + "content": "# 开发指南\n\n[English](development.md) | 中文\n\n搭建教程引导新贡献者从准备前置条件开始,直到检出通过检查。后面的贡献者参考介绍仓库布局、日常工作流和 CI 形态。设计依据与实现细节属于链接的 Agent Note 和脚本。\n\n## 搭建教程\n\n### 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 Web、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n### 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程还会通过 `scripts/install-lefthook.mjs` 配置 worktree 本地的 lefthook 钩子。其安全与迁移契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 负责。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n如果包装脚本拒绝现有 Git 配置或报告陈旧锁,请遵循其诊断和所链接的 Agent Note,不要凭猜测编辑 worktree 元数据。移动检出目录后,请重新运行包装脚本以重新生成自有路径。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n`pnpm run typecheck` 成功退出即表示搭建完成。\n\n## 贡献者参考\n\n### TypeScript 项目布局\n\n仓库类型检查会执行全仓 `tsc -b` 图:它会发射每个 package/vendor 的 `lib/types`,并通过两个 no-emit 聚合检查示例、测试和脚本。\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。\n\n业务 Service 在 Host 使用 `@Remote` 或 `@RemoteScope` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并挂到 `ctx.remote` 与作用域 `agentCtx.remote` namespace。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n### 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n### Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 应用仅用于格式化的 ESLint 修复,使用 Oxlint 验证暂存文件并应用其原生修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n### CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n### 日常命令\n\n根目录的[贡献者说明](../AGENTS.md#commands)概述常用命令,[`package.json`](../package.json) 与 [scripts/run-gates.ts](../scripts/run-gates.ts) 则负责当前脚本和门禁清单。请选择覆盖变更表面的最小检查集。文档变更使用 `pnpm run doc-sync`;package 公开行为变更还需更新所属 README 或 JSDoc,而基于构建产物的检查需要先运行 `pnpm run build`。\n\n### 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n### 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n" }, { "role": "user", From 71ebeaa55985a4033c86793f6c91fc0fe65cf8b0 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:43:34 +0800 Subject: [PATCH 102/104] fix(client-runtime): localize remote namespace dependency --- docs/api-gateway.i18n.yaml | 4 ++-- docs/api-gateway.md | 4 +++- docs/api-gateway.zh.md | 4 +++- packages/client/runtime/src/client/index.ts | 4 ++-- packages/client/runtime/tests/client-apply.spec.ts | 1 - packages/client/runtime/tests/wire-events.spec.ts | 1 - 6 files changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index 2a6ae0807b..074644ff3e 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: ba95d429dd0c9f9f354baf0063197cea6e3ecbf8 -api-gateway.zh.md: 4e42ebea7a5db19c7df23079050b9488679a3a23 +api-gateway.md: 33dfb30c9da25e46b660a3fa54ef37f587cbda08 +api-gateway.zh.md: 633eb10c0f2f065ecf27545813cc17d79f391865 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index ba95d429dd..33dfb30c9d 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -55,7 +55,7 @@ export class GoalService extends GatewayService { Remote methods may return a value synchronously or return a Promise. For cooperative cancellation, the final parameter in the Host signature must be `signal: AbortSignal` using the global type; it is recorded in the descriptor instead of entering `args`, while the generated Client method accepts an optional final `AbortSignal`. -The Client uses concrete functions on ordinary objects, not a JavaScript Proxy. Direct and scoped calls appear under `ctx.remote.` and `agentCtx.remote.`. Each namespace is a traced Cordis child Service registered as `remote.`; the Client assembly mounts contributions through `ctx.remote.$mount()`, consumers inject both `remote` and the namespace Service they call, and the namespace unloads after its last method is withdrawn. When an `@Remote` method has exactly one lookup parameter and a same-named `TypeRTContextMap` uses the same wire identity, the generated scoped signature omits that identity parameter. `@RemoteScope` generates only the scoped invocation interface. +The Client uses concrete functions on ordinary objects, not a JavaScript Proxy. Direct and scoped calls appear under `ctx.remote.` and `agentCtx.remote.`. Each namespace is a traced Cordis child Service registered as `remote.`; the Client assembly mounts contributions through `ctx.remote.$mount()`, and the namespace unloads after its last method is withdrawn. Dependency declarations belong to the actual caller: only a business package that reads `ctx.remote.` or `agentCtx.remote.` declares both `remote` and `remote.` in its own `inject`; assemblies that only mount contributions and higher-level runtimes that do not call that namespace do not declare the namespace dependency on the business package's behalf. When an `@Remote` method has exactly one lookup parameter and a same-named `TypeRTContextMap` uses the same wire identity, the generated scoped signature omits that identity parameter. `@RemoteScope` generates only the scoped invocation interface. ```ts import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -63,6 +63,8 @@ import type { AgentContext } from '@deepseek-ai/dsh-client-runtime/client' import type { Context } from 'cordis' import type {} from '@deepseek-ai/dsh-api-remotes/client' +export const inject = ['remote', 'remote.goals'] + declare const ctx: Context declare const agentCtx: AgentContext declare const agentId: SessionId diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index 4e42ebea7a..633eb10c0f 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -55,7 +55,7 @@ export class GoalService extends GatewayService { Remote 方法可以同步返回或返回 Promise。若需要协作式取消,Host 签名的最后一个参数必须是全局类型的 `signal: AbortSignal`;它记录在描述符中而不是进入 `args`,Client 生成的方法则接受最后一个可选的 `AbortSignal`。 -Client 使用普通对象上的具体函数,不使用 JavaScript Proxy。直接调用与作用域调用分别出现在 `ctx.remote.` 和 `agentCtx.remote.`。每个 namespace 都是注册为 `remote.` 的可追踪 Cordis 子 Service;Client assembly 通过 `ctx.remote.$mount()` 挂载贡献,消费方同时注入 `remote` 与所调用的 namespace Service,最后一个方法撤回后该 namespace 随即卸载。当一个 `@Remote` 方法恰好有一个 lookup 参数、且同名 `TypeRTContextMap` 使用相同 wire identity 时,生成的作用域签名会省略该 identity 参数。`@RemoteScope` 只生成作用域调用界面。 +Client 使用普通对象上的具体函数,不使用 JavaScript Proxy。直接调用与作用域调用分别出现在 `ctx.remote.` 和 `agentCtx.remote.`。每个 namespace 都是注册为 `remote.` 的可追踪 Cordis 子 Service;Client assembly 通过 `ctx.remote.$mount()` 挂载贡献,最后一个方法撤回后该 namespace 随即卸载。依赖声明归实际调用方所有:只有读取 `ctx.remote.` 或 `agentCtx.remote.` 的业务包才在自己的 `inject` 中同时声明 `remote` 与 `remote.`;只负责挂载 contribution 的 assembly,以及不调用该 namespace 的上层 runtime,不代业务包声明 namespace 依赖。当一个 `@Remote` 方法恰好有一个 lookup 参数、且同名 `TypeRTContextMap` 使用相同 wire identity 时,生成的作用域签名会省略该 identity 参数。`@RemoteScope` 只生成作用域调用界面。 ```ts import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -63,6 +63,8 @@ import type { AgentContext } from '@deepseek-ai/dsh-client-runtime/client' import type { Context } from 'cordis' import type {} from '@deepseek-ai/dsh-api-remotes/client' +export const inject = ['remote', 'remote.goals'] + declare const ctx: Context declare const agentCtx: AgentContext declare const agentId: SessionId diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index b772e315a3..5a1677df96 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -179,8 +179,8 @@ declare module 'cordis' { } } -/** Required services: the Remote root and Goal namespace, wire handle, and Client TypeRT registry. */ -export const inject = ['remote', 'remote.goals', 'connection', 'typert'] +/** Required services: the Remote root, wire handle, and Client TypeRT registry. */ +export const inject = ['remote', 'connection', 'typert'] /** Mounts the browser runtime services and connection stream. * @param ctx - Client Cordis context. diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index e9b387fb00..b700c4c066 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -39,7 +39,6 @@ async function mount(): Promise { } ctx.reflect.provide('connection', handle) ctx.reflect.provide('remote', {}) - ctx.reflect.provide('remote.goals', {}) await ctx.plugin(RuntimeClient).await() return bench } diff --git a/packages/client/runtime/tests/wire-events.spec.ts b/packages/client/runtime/tests/wire-events.spec.ts index 703c5b1728..dfafcd07aa 100644 --- a/packages/client/runtime/tests/wire-events.spec.ts +++ b/packages/client/runtime/tests/wire-events.spec.ts @@ -33,7 +33,6 @@ async function mount(): Promise { } ctx.reflect.provide('connection', handle) ctx.reflect.provide('remote', {}) - ctx.reflect.provide('remote.goals', {}) await ctx.plugin(RuntimeClient).await() return bench } From 38c373af65923abcd8fff7c200f5a64ca93b617b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 7 Aug 2026 22:41:09 +0800 Subject: [PATCH 103/104] fix(ci): keep issue policy test discovery focused --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9698d0d2fa..81cb40acad 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "test": "vitest run", "test:coverage": "vitest run --coverage", "test:e2e": "vitest run --config vitest.e2e.config.ts", - "test:issue-management": "node --test .github/issue-management/policy.test.mjs", + "test:issue-management": "node .github/issue-management/policy.test.mjs", "test:snapshot": "vitest run --config vitest.snapshot.config.ts", "test:snapshot:record": "DSH_SNAPSHOT=record vitest run --config vitest.snapshot.config.ts --update", "test:snapshot:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.snapshot.config.ts", From 1c23f196fefb6d68075aab73599ca988308f1ae1 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:05:55 +0800 Subject: [PATCH 104/104] fix(ui): use official hero title casing --- apps/web/tests/details-session-lifecycle.e2e.ts | 2 +- apps/web/tests/hmr-live.e2e.ts | 4 ++-- apps/web/tests/lifecycle-chrome.e2e.ts | 2 +- apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md | 2 +- .../tests/snapshots/lifecycle-chrome/plan-active.expected.md | 2 +- apps/web/tests/startup-auto-selection.e2e.ts | 2 +- packages/client/ui-conversation/src/client/locales.ts | 2 +- packages/client/ui-conversation/tests/skeleton.spec.tsx | 2 +- 8 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts index 3bd781a5ee..c88a823fb7 100644 --- a/apps/web/tests/details-session-lifecycle.e2e.ts +++ b/apps/web/tests/details-session-lifecycle.e2e.ts @@ -121,7 +121,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false) await page.getByRole('button', { name: /^(?:New session|新.*会话)$/ }).last().click() - await page.getByText('Into the unknown', { exact: false }).waitFor({ timeout: 15_000 }) + await page.getByText('Into the Unknown', { exact: false }).waitFor({ timeout: 15_000 }) await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0) expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false) diff --git a/apps/web/tests/hmr-live.e2e.ts b/apps/web/tests/hmr-live.e2e.ts index 1e8e81909f..cafd0fb474 100644 --- a/apps/web/tests/hmr-live.e2e.ts +++ b/apps/web/tests/hmr-live.e2e.ts @@ -75,8 +75,8 @@ it('hot-reloads a real client-plugin source edit without refreshing the page', a if (!existsSync(binPath)) throw new Error('HMR browser test needs the built dsh bin; run pnpm run build first') const originalSource = await readFile(sourcePath) const originalBundle = await readFile(bundlePath) - const oldText = 'Into the unknown' - const sourceNeedle = "'hero.headline': 'Into the unknown'" + const oldText = 'Into the Unknown' + const sourceNeedle = "'hero.headline': 'Into the Unknown'" const newText = `HMR UPDATED ${'x'.repeat(80)}` const updatedSource = originalSource.toString().replace(sourceNeedle, `'hero.headline': '${newText}'`) if (updatedSource === originalSource.toString()) throw new Error(`HMR source lacks ${JSON.stringify(sourceNeedle)}`) diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index 90587e9e4c..1aa6b3dea9 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -159,7 +159,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () } // The blank frame renders the hero, not the resident composer: the // headline plus the guidance placeholder are the empty state's anchors. - await expect.poll(() => page.getByText('Into the unknown', { exact: false }).count(), { timeout: 15_000 }).toBe(1) + await expect.poll(() => page.getByText('Into the Unknown', { exact: false }).count(), { timeout: 15_000 }).toBe(1) const input = page.locator('textarea').first() await input.waitFor({ timeout: 10_000 }) if (MODE !== 'record') { diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md index ad060c5d59..dfa23ca508 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md @@ -20,7 +20,7 @@ - button "Settings": - img - text: Settings -- text: Into the unknown Preview +- text: Into the Unknown Preview - button "Choose workspace": - img - text: workspace diff --git a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md index ce2ce36af0..3bf7e93148 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md @@ -20,7 +20,7 @@ - button "Settings": - img - text: Settings -- text: Into the unknown Preview +- text: Into the Unknown Preview - button "Choose workspace": - img - text: workspace diff --git a/apps/web/tests/startup-auto-selection.e2e.ts b/apps/web/tests/startup-auto-selection.e2e.ts index c93ed04a40..21ad64e4ed 100644 --- a/apps/web/tests/startup-auto-selection.e2e.ts +++ b/apps/web/tests/startup-auto-selection.e2e.ts @@ -145,7 +145,7 @@ describe('web e2e: startup auto-selection', () => { // seat with `visibility:hidden`, which Playwright reports as not visible). await page.waitForSelector(ROOT_PHASE, { timeout: 15_000 }) expect(await page.locator(ROOT_PHASE).first().getAttribute('data-phase')).toBe('hero') - expect(await page.getByText('Into the unknown').isVisible()).toBe(true) + expect(await page.getByText('Into the Unknown').isVisible()).toBe(true) expect(await page.locator('textarea').first().isVisible()).toBe(true) releaseHistory() diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index eec25939b3..df107d2cd2 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -184,7 +184,7 @@ export const en = { 'access.confirm.acknowledge': 'I understand the risks and want to continue', 'access.confirm.cancel': 'Cancel', 'access.confirm.enable': 'Enable Full access', - 'hero.headline': 'Into the unknown', + 'hero.headline': 'Into the Unknown', 'hero.preview': 'Preview', 'hero.chooseWorkspace': 'Choose workspace', 'session.hierarchy': 'Session hierarchy', diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 1cf97bb61a..f2da8be7cb 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -242,7 +242,7 @@ function mount( describe('Hero chrome', () => { it('renders the English preview badge through the hero locale seat', () => { const view = render() - expect(view.getByText('Into the unknown')).toBeTruthy() + expect(view.getByText('Into the Unknown')).toBeTruthy() expect(view.getByText('Preview')).toBeTruthy() }) })