From 0ccd3ed4638f5ae10771cc74147fcfb8a92a7e2d Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 13:34:45 +0800 Subject: [PATCH 01/67] 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 02/67] 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 03/67] 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 04/67] 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 05/67] 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 06/67] 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 07/67] 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 08/67] 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 09/67] 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 1ee167aeaca76ef483db6d2e3a2c6ba1f110161f Mon Sep 17 00:00:00 2001 From: _Kerman <kermanx@qq.com> Date: Mon, 3 Aug 2026 19:49:30 +0800 Subject: [PATCH 10/67] feat(fs): append recovery remedy to guarded-mutation errors write/edit failures with FS_STALE_VERSION or FS_NOT_OBSERVED now reach the model with the correct recovery instruction appended (re-read / read, then retry) while preserving the structured code and chaining the cause. The edit-intent waterfall sits inside the same try, so the policy's FS_NOT_OBSERVED refusal is remediated too. Re-recorded the fs-policy-reject keyless snapshot and the bilingual README pairs. --- .../snapshots/fs-policy-reject/session.jsonl | 2 +- packages/fs/fs-policy/README.i18n.yaml | 4 +- packages/fs/fs-policy/README.md | 2 +- packages/fs/fs-policy/README.zh.md | 2 +- packages/fs/tool-fs/README.i18n.yaml | 4 +- packages/fs/tool-fs/README.md | 2 +- packages/fs/tool-fs/README.zh.md | 2 +- packages/fs/tool-fs/src/edit.ts | 14 +++- packages/fs/tool-fs/src/error.ts | 34 ++++++++ packages/fs/tool-fs/src/write.ts | 6 +- packages/fs/tool-fs/tests/error.spec.ts | 35 ++++++++ packages/fs/tool-fs/tests/integration.spec.ts | 80 +++++++++++++++++++ packages/fs/tool-fs/tests/tools.spec.ts | 3 +- 13 files changed, 174 insertions(+), 16 deletions(-) create mode 100644 packages/fs/tool-fs/src/error.ts create mode 100644 packages/fs/tool-fs/tests/error.spec.ts diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl b/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl index 07d1408c9e..87cd7427f9 100644 --- a/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl @@ -16,7 +16,7 @@ {"type":"assistant/chunk","seq":78,"time":1785487602271,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":79,"time":1785487602271,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to use the edit tool to replace \"blue\" with \"green\" in settings.txt without reading the file first, and then reply with just \"DONE\"."},{"type":"tool-call","id":"call_00_x0zlnXl5JOxLrAYL9y7P0119","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"8bd34189-fb62-4106-9c25-b6022d48e059"},"usage":{"inputTokens":3132,"outputTokens":115,"cacheReadTokens":0,"reasoningTokens":36}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78],"surfaceOp":"append"} {"type":"tool/call","seq":80,"time":1785487602272,"data":{"turn":1,"step":1,"callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}} -{"type":"tool/result","seq":81,"time":1785487602280,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119"},"content":[{"type":"tool-result","toolCallId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","content":[{"type":"text","text":"Error: edit requires reading \"{{cwd}}/settings.txt\" first"}],"isError":true}],"role":"user","id":"c4018c31-b6fd-4f14-af3c-e609863bf501"},"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[80],"surfaceOp":"append"} +{"type":"tool/result","seq":81,"time":1785487602280,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119"},"content":[{"type":"tool-result","toolCallId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","content":[{"type":"text","text":"Error: edit requires reading \"{{cwd}}/settings.txt\" first — read the file, then retry"}],"isError":true}],"role":"user","id":"c4018c31-b6fd-4f14-af3c-e609863bf501"},"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[80],"surfaceOp":"append"} {"type":"step/end","seq":82,"time":1785487602280,"data":{"turn":1,"step":1}} {"type":"step/start","seq":83,"time":1785487602287,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":84,"time":1783611704931,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/packages/fs/fs-policy/README.i18n.yaml b/packages/fs/fs-policy/README.i18n.yaml index 6690227dbc..5168b43d34 100644 --- a/packages/fs/fs-policy/README.i18n.yaml +++ b/packages/fs/fs-policy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/fs/fs-policy/README.md -README.md: dc4e9377793570c80b8d71ec84196bebe7fe583a -README.zh.md: aa0cb25899f5906ac9f531583ba48d01ad6095b4 +README.md: f6b3292bdc6e5565df0393a59c50d4e594921401 +README.zh.md: 2b30e6223719301df776b5d1cb7c674cb7ef7ff7 diff --git a/packages/fs/fs-policy/README.md b/packages/fs/fs-policy/README.md index dc4e937779..f6b3292bdc 100644 --- a/packages/fs/fs-policy/README.md +++ b/packages/fs/fs-policy/README.md @@ -55,7 +55,7 @@ Because the plugin influences the world only through events, removing it does no #### What the model sees -This plugin adds no prompt or schema. It rejects an edit without a prior read with code `FS_NOT_OBSERVED` and exact message `edit requires reading "<path>" first`. Guarded mutations whose observed version is stale propagate the provider-owned `FS_STALE_VERSION` error. [`dsh-tool-fs`](../tool-fs/README.md) owns the model-facing error wrapper; observation state is never shown. +This plugin adds no prompt or schema. It rejects an edit without a prior read with code `FS_NOT_OBSERVED` and exact message `edit requires reading "<path>" first`. Guarded mutations whose observed version is stale propagate the provider-owned `FS_STALE_VERSION` error. [`dsh-tool-fs`](../tool-fs/README.md) owns the model-facing error wrapper, which appends the recovery instruction to `FS_STALE_VERSION` (`— re-read the file, then retry`) and `FS_NOT_OBSERVED` (`— read the file, then retry`) messages while preserving the code; observation state is never shown. #### Token effect diff --git a/packages/fs/fs-policy/README.zh.md b/packages/fs/fs-policy/README.zh.md index aa0cb25899..2b30e62237 100644 --- a/packages/fs/fs-policy/README.zh.md +++ b/packages/fs/fs-policy/README.zh.md @@ -55,7 +55,7 @@ await ctx.plugin(FsPolicy) #### 模型看到的内容 -该插件不添加提示词或 schema。编辑前未读取时,它会以代码 `FS_NOT_OBSERVED` 和精确消息 `edit requires reading "<path>" first` 拒绝。观察版本陈旧的防护变更会传播由提供方拥有的 `FS_STALE_VERSION` 错误。[`dsh-tool-fs`](../tool-fs/README.md)拥有面向模型的错误包装;观察状态绝不会显示。 +该插件不添加提示词或 schema。编辑前未读取时,它会以代码 `FS_NOT_OBSERVED` 和精确消息 `edit requires reading "<path>" first` 拒绝。观察版本陈旧的防护变更会传播由提供方拥有的 `FS_STALE_VERSION` 错误。[`dsh-tool-fs`](../tool-fs/README.md)拥有面向模型的错误包装,会为 `FS_STALE_VERSION` 消息追加恢复指令(`— re-read the file, then retry`)、为 `FS_NOT_OBSERVED` 消息追加恢复指令(`— read the file, then retry`),同时保留错误码;观察状态绝不会显示。 #### Token 影响 diff --git a/packages/fs/tool-fs/README.i18n.yaml b/packages/fs/tool-fs/README.i18n.yaml index fbe2e69043..8f462ed19b 100644 --- a/packages/fs/tool-fs/README.i18n.yaml +++ b/packages/fs/tool-fs/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/fs/tool-fs/README.md -README.md: a695d0ba8fb1d600689d2b68763e8423d1591da5 -README.zh.md: 5c600ab70b46da640637aec64efc1c0f0d0d54c0 +README.md: 246b1c8797e9a2ddc630724729edf8e2f1185bfc +README.zh.md: 6cfc3d750b0f6ffc9ee886f4d0f058885bc19083 diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index a695d0ba8f..246b1c8797 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -136,7 +136,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -Failures are normalized as `Error: <message>`. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to <max>`, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "<path>": not found`, `cannot read "<path>": not a regular file`, and `offset <offset> is out of range for "<path>" (<total> lines)`; provider and policy templates are quoted in their package READMEs. +Failures are normalized as `Error: <message>`. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to <max>`, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "<path>": not found`, `cannot read "<path>": not a regular file`, and `offset <offset> is out of range for "<path>" (<total> lines)`; provider and policy templates are quoted in their package READMEs. Guarded-mutation failures additionally carry their recovery instruction in the message, appended by this package's model-facing error wrapper: `FS_STALE_VERSION` (including a missing edit target) gets `— re-read the file, then retry`, `FS_NOT_OBSERVED` gets `— read the file, then retry`; the structured code is preserved. #### Token effect diff --git a/packages/fs/tool-fs/README.zh.md b/packages/fs/tool-fs/README.zh.md index 5c600ab70b..6cfc3d750b 100644 --- a/packages/fs/tool-fs/README.zh.md +++ b/packages/fs/tool-fs/README.zh.md @@ -136,7 +136,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces #### 模型看到的内容 -失败会规范化为 `Error: <message>`。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`limit must be less than or equal to <max>`、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "<path>": not found`、`cannot read "<path>": not a regular file` 和 `offset <offset> is out of range for "<path>" (<total> lines)`;提供方和策略模板在各自包的 README 中逐字列出。 +失败会规范化为 `Error: <message>`。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`limit must be less than or equal to <max>`、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "<path>": not found`、`cannot read "<path>": not a regular file` 和 `offset <offset> is out of range for "<path>" (<total> lines)`;提供方和策略模板在各自包的 README 中逐字列出。防护变更失败还会在消息中携带恢复指令,由本包面向模型的错误包装追加:`FS_STALE_VERSION`(包括编辑目标缺失)追加 `— re-read the file, then retry`,`FS_NOT_OBSERVED` 追加 `— read the file, then retry`;结构化错误码保持不变。 #### Token 影响 diff --git a/packages/fs/tool-fs/src/edit.ts b/packages/fs/tool-fs/src/edit.ts index 951c0b7b57..fcd04cb17c 100644 --- a/packages/fs/tool-fs/src/edit.ts +++ b/packages/fs/tool-fs/src/edit.ts @@ -11,6 +11,7 @@ import type { DiffCallView, DiffResultView, ToolResult } from '@deepseek-ai/dsh- import type {} from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-system-prompt' import { computeHunkDiffs, diffsFromMeta } from './diff.ts' +import { remediateFsError } from './error.ts' import { sessionResolveOptions } from './session-cwd.ts' import type { FsSandboxSurface } from './sandbox.ts' @@ -116,10 +117,13 @@ export function applyEditTool(ctx: Context, sandbox: FsSandboxSurface): void { const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec, input.filePath, sandboxPolicy?.workspaceRoot)) // Single-slot decision: the policy plugin returns { version: vObserved } or // throws FS_NOT_OBSERVED; the bare default is undefined (unconditional edit). - // No stat — the bare default never manufactures a version basis. - const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined) + // No stat — the bare default never manufactures a version basis. The intent + // slot itself can throw FS_NOT_OBSERVED for an unread target, so it sits + // inside the try: both that refusal and the provider's guarded-mutation + // failure get the model-facing remedy below. let outcome try { + const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined) outcome = await ctx.fs.editText( target, { oldString: input.oldString, newString: input.newString, replaceAll: input.replaceAll }, @@ -128,8 +132,10 @@ export function applyEditTool(ctx: Context, sandbox: FsSandboxSurface): void { sandboxPolicy, ) } catch (error: unknown) { - // A sandbox denial becomes the shared [sandbox: …] marker; any other error passes through. - throw sandbox.mapError(error, sandboxPolicy) + // A sandbox denial becomes the shared [sandbox: …] marker (the model + // recognizes it from bash); stale/not-observed failures gain their + // model-facing remedy; anything else passes through. + throw remediateFsError(sandbox.mapError(error, sandboxPolicy)) } // Record the observed version (a no-op when no policy plugin listens). ctx.emit('fs/observed', target, outcome.version, exec) diff --git a/packages/fs/tool-fs/src/error.ts b/packages/fs/tool-fs/src/error.ts new file mode 100644 index 0000000000..e67616887f --- /dev/null +++ b/packages/fs/tool-fs/src/error.ts @@ -0,0 +1,34 @@ +/** + * Model-facing remediation for guarded-mutation failures. The provider's + * `FS_STALE_VERSION` and `FS_NOT_OBSERVED` messages state the condition but + * not the only correct recovery (re-read / read the file), so this package + * appends the remedy at the model boundary; provider messages stay + * machine-oriented and unchanged. + * @module @deepseek-ai/dsh-tool-fs/src/error + */ + +import { FsError } from '@deepseek-ai/dsh-fs' +import type { FsErrorCode } from '@deepseek-ai/dsh-fs' + +/** The remedy appended to each remediable failure code's message. */ +const REMEDIES: Partial<Record<FsErrorCode, string>> = { + FS_STALE_VERSION: 're-read the file, then retry', + FS_NOT_OBSERVED: 'read the file, then retry', +} + +/** + * Append the correct recovery instruction to a guarded-mutation failure's + * message. `FS_STALE_VERSION` (the file changed since this session's last + * observation, including a missing target) recovers only by re-reading; + * `FS_NOT_OBSERVED` (no prior read by this session) by reading. The `FsError` + * code is preserved so retry/permission/UI layers keep routing on it, and the + * original error chains as `cause`. Anything else passes through untouched. + * @param error - the caught value from a write/edit execution. + * @returns a remediated `FsError` for the two guarded-mutation codes, else the original value. + */ +export function remediateFsError(error: unknown): unknown { + if (!(error instanceof FsError)) return error + const remedy = REMEDIES[error.code] + if (!remedy) return error + return new FsError(`${error.message} — ${remedy}`, error.code, { cause: error }) +} diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts index 37a6d67e59..56e2be488b 100644 --- a/packages/fs/tool-fs/src/write.ts +++ b/packages/fs/tool-fs/src/write.ts @@ -12,6 +12,7 @@ import type { FsWriteOutcome } from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-system-prompt' import { computeHunkDiffs, diffsFromMeta } from './diff.ts' +import { remediateFsError } from './error.ts' import { sessionResolveOptions } from './session-cwd.ts' import type { FsSandboxSurface } from './sandbox.ts' @@ -113,8 +114,9 @@ export function applyWriteTool(ctx: Context, sandbox: FsSandboxSurface): void { outcome = await ctx.fs.writeText(target, input.content, intent, exec.signal, sandboxPolicy) } catch (error: unknown) { // A sandbox denial becomes the shared [sandbox: …] marker (the model - // recognizes it from bash); any other error passes through. - throw sandbox.mapError(error, sandboxPolicy) + // recognizes it from bash); stale/not-observed failures gain their + // model-facing remedy; anything else passes through. + throw remediateFsError(sandbox.mapError(error, sandboxPolicy)) } // Record the observed version (a no-op when no policy plugin listens). ctx.emit('fs/observed', target, outcome.version, exec) diff --git a/packages/fs/tool-fs/tests/error.spec.ts b/packages/fs/tool-fs/tests/error.spec.ts new file mode 100644 index 0000000000..671eb32d9d --- /dev/null +++ b/packages/fs/tool-fs/tests/error.spec.ts @@ -0,0 +1,35 @@ +/** + * Unit tests for the model-facing error remediation: the remedy appended to + * guarded-mutation failures, code preservation, and passthrough behavior. + */ + +import { describe, expect, it } from 'vitest' +import { FsError } from '@deepseek-ai/dsh-fs' +import { remediateFsError } from '../src/error.ts' + +describe('remediateFsError', () => { + it('appends the re-read remedy to FS_STALE_VERSION, preserving the code and chaining the cause', () => { + const original = new FsError('cannot edit "x": file changed since it was read', 'FS_STALE_VERSION') + const remedied = remediateFsError(original) as FsError + expect(remedied).toBeInstanceOf(FsError) + expect(remedied.message).toBe('cannot edit "x": file changed since it was read — re-read the file, then retry') + expect(remedied.code).toBe('FS_STALE_VERSION') + expect(remedied.cause).toBe(original) + }) + + it('appends the read remedy to FS_NOT_OBSERVED', () => { + const remedied = remediateFsError(new FsError('edit requires reading "x" first', 'FS_NOT_OBSERVED')) as FsError + expect(remedied.message).toBe('edit requires reading "x" first — read the file, then retry') + expect(remedied.code).toBe('FS_NOT_OBSERVED') + }) + + it('leaves other FsError codes untouched', () => { + const original = new FsError('no match anywhere', 'FS_EDIT_NOT_FOUND') + expect(remediateFsError(original)).toBe(original) + }) + + it('leaves non-FsError values untouched', () => { + const original = new Error('boom') + expect(remediateFsError(original)).toBe(original) + }) +}) diff --git a/packages/fs/tool-fs/tests/integration.spec.ts b/packages/fs/tool-fs/tests/integration.spec.ts index c835baebb9..3482b38569 100644 --- a/packages/fs/tool-fs/tests/integration.spec.ts +++ b/packages/fs/tool-fs/tests/integration.spec.ts @@ -71,6 +71,9 @@ describe('default deployment (with dsh-fs-policy)', () => { const result = await call('write', { file_path: 'a.txt', content: 'clobber' }) expect(result.isError).toBe(true) expect(result.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } }) + // The model-facing text names the remedy, not just the condition. + expect(text(result)).toContain('without reading it first') + expect(text(result)).toContain('read the file, then retry') expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('original') }) @@ -89,6 +92,23 @@ describe('default deployment (with dsh-fs-policy)', () => { const result = await call('write', { file_path: 'a.txt', content: 'replaced' }) expect(result.isError).toBe(true) expect(result.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } }) + // The model-facing text names the remedy, not just the condition. + expect(text(result)).toContain('file changed since it was read') + expect(text(result)).toContain('re-read the file, then retry') + }) + + it('the stale remedy is actionable: re-reading the changed file unblocks the retried write', async () => { + await writeFile(join(dir, 'a.txt'), 'original') + await call('read', { file_path: 'a.txt' }) + await writeFile(join(dir, 'a.txt'), 'changed-externally') // out-of-band change + const stale = await call('write', { file_path: 'a.txt', content: 'replaced' }) + expect(stale.isError).toBe(true) + expect(stale.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } }) + // Follow the remedy: re-read (refreshes the observed version), then retry. + expect((await call('read', { file_path: 'a.txt' })).isError).toBe(false) + const retried = await call('write', { file_path: 'a.txt', content: 'replaced' }) + expect(retried.isError).toBe(false) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('replaced') }) }) @@ -131,6 +151,9 @@ describe('default deployment (with dsh-fs-policy)', () => { const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' }) expect(result.isError).toBe(true) expect(result.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } }) + // The policy's refusal reaches the model with the read remedy appended. + expect(text(result)).toContain('edit requires reading') + expect(text(result)).toContain('read the file, then retry') expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello world') }) @@ -155,6 +178,23 @@ describe('default deployment (with dsh-fs-policy)', () => { const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' }) expect(result.isError).toBe(true) expect(result.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } }) + // The model-facing text names the remedy, not just the condition. + expect(text(result)).toContain('file changed since it was read') + expect(text(result)).toContain('re-read the file, then retry') + }) + + it('the stale remedy is actionable: re-reading the changed file unblocks the retried edit', async () => { + await writeFile(join(dir, 'a.txt'), 'hello world') + await call('read', { file_path: 'a.txt' }) + await writeFile(join(dir, 'a.txt'), 'hello brave world') // out-of-band change + const stale = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' }) + expect(stale.isError).toBe(true) + expect(stale.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } }) + // Follow the remedy: re-read (refreshes the observed version), then retry. + expect((await call('read', { file_path: 'a.txt' })).isError).toBe(false) + const retried = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' }) + expect(retried.isError).toBe(false) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello brave there') }) it('rejects an ambiguous match without replace_all', async () => { @@ -194,6 +234,43 @@ describe('default deployment (with dsh-fs-policy)', () => { }) }) + describe('deleted observed target (fail-closed corner)', () => { + it('a deleted observed file stays un-writable and un-editable in-session: the remedy cannot unblock it', async () => { + await writeFile(join(dir, 'a.txt'), 'original') + await call('read', { file_path: 'a.txt' }) + await rm(join(dir, 'a.txt')) // out-of-band deletion + + // Edit of the missing target: stale (the missing-target path shares the + // stale code and the re-read remedy). + const edit = await call('edit', { file_path: 'a.txt', old_string: 'original', new_string: 'x' }) + expect(edit.isError).toBe(true) + expect(edit.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } }) + + // Re-reading the missing file FAILS with FS_NOT_FOUND and records no + // observation, so the retried edit fails identically: the observed entry + // is never cleared for a deleted target. + const reread = await call('read', { file_path: 'a.txt' }) + expect(reread.isError).toBe(true) + expect(reread.error).toMatchObject({ info: { code: 'FS_NOT_FOUND' } }) + const retriedEdit = await call('edit', { file_path: 'a.txt', old_string: 'original', new_string: 'x' }) + expect(retriedEdit.isError).toBe(true) + expect(retriedEdit.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } }) + + // Write cannot recreate it either: the stale observation still forces + // replaceIfVersion, which rejects a missing target ("file no longer exists"). + const write = await call('write', { file_path: 'a.txt', content: 'fresh' }) + expect(write.isError).toBe(true) + expect(write.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } }) + + // The dead end lifts once the file exists again and is freshly observed. + await writeFile(join(dir, 'a.txt'), 'restored') + expect((await call('read', { file_path: 'a.txt' })).isError).toBe(false) + const recovered = await call('write', { file_path: 'a.txt', content: 'fresh' }) + expect(recovered.isError).toBe(false) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('fresh') + }) + }) + describe('stat budget', () => { it('read stats once; write and edit never stat in the tool (the gate stats zero too)', async () => { await writeFile(join(dir, 'a.txt'), 'hello world') @@ -264,6 +341,9 @@ describe('bare provider (no dsh-fs-policy)', () => { const result = await call('edit', { file_path: 'missing.txt', old_string: 'a', new_string: 'b' }) expect(result.isError).toBe(true) expect(result.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } }) + // Even without policy, the stale text carries the re-read remedy. + expect(text(result)).toContain('file changed since it was read') + expect(text(result)).toContain('re-read the file, then retry') }) it('edit still enforces literal-match codes (FS_EDIT_NOT_FOUND), unrelated to freshness', async () => { diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 914a1bf7de..ad01237c2b 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -397,12 +397,13 @@ describe('write tool', () => { expect(text(result)).toContain('file_path must be a non-empty string') }) - it('propagates a backend FsError as an isError result carrying its code', async () => { + it('propagates a backend FsError as an isError result carrying its code and remedy', async () => { const { ctx, fs } = await setup() fs.rejectWith = new FsError('blocked', 'FS_STALE_VERSION') const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'hi' }) expect(result.isError).toBe(true) expect(result.error).toMatchObject({ info: { name: 'FsError', code: 'FS_STALE_VERSION' } }) + expect(text(result)).toContain('re-read the file, then retry') }) }) From 044df0e0c8b30f1dab97db8e473317dc66b060c0 Mon Sep 17 00:00:00 2001 From: _Kerman <kermanx@qq.com> Date: Mon, 3 Aug 2026 19:49:38 +0800 Subject: [PATCH 11/67] docs(notes): record model-facing error remedy decision The tool-fs error wrapper decision: guarded-mutation failures gain their recovery instruction at the model boundary while the provider messages and structured codes stay unchanged; includes the deleted-target fail-closed corner. --- .../2026-08-03-fs-tool-error-remedy.i18n.yaml | 6 ++++ .../2026-08-03-fs-tool-error-remedy.md | 32 +++++++++++++++++++ .../2026-08-03-fs-tool-error-remedy.zh.md | 32 +++++++++++++++++++ 3 files changed, 70 insertions(+) create mode 100644 .agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.md create mode 100644 .agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.i18n.yaml b/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.i18n.yaml new file mode 100644 index 0000000000..98500c284e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.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-03-fs-tool-error-remedy.md +2026-08-03-fs-tool-error-remedy.md: f227c31365725652b130e097d70c79d3daab3684 +2026-08-03-fs-tool-error-remedy.zh.md: 11acd0cf48924833ced91591d5ea1424735969cd diff --git a/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.md b/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.md new file mode 100644 index 0000000000..f227c31365 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.md @@ -0,0 +1,32 @@ +# Agent Note: Guarded-mutation errors append the recovery instruction at the model boundary + +Status: implemented + +English | [中文](2026-08-03-fs-tool-error-remedy.zh.md) + +## Problem + +Guarded `write` and `edit` failures reach the model with messages that state the condition but not the only correct recovery: `FS_STALE_VERSION` ("file changed since it was read") and `FS_NOT_OBSERVED` ("edit requires reading … first"). The model must guess that the recovery is a re-read (or a first read) followed by a retry, and the retry/permission/UI layers that route on the structured code see the same message text. The provider-owned messages are part of the storage seam's machine-oriented vocabulary ([filesystem capability seam](../architecture/2026-06-17-filesystem-capability-seam.md)), so the remedy cannot live there without leaking model-facing wording into every consumer of `FsError`. + +## Decision + +`dsh-tool-fs` owns a model-facing error wrapper, `remediateFsError` in `src/error.ts`, applied in `write.ts` and `edit.ts` after the sandbox denial mapping. It appends the recovery instruction to the two guarded-mutation codes and passes everything else through untouched: + +- `FS_STALE_VERSION` (including a missing edit target, which shares the stale code) gains `— re-read the file, then retry`. +- `FS_NOT_OBSERVED` gains `— read the file, then retry`. + +The structured `FsError` code is preserved so retry/permission/UI layers keep routing on it, and the original error chains as `cause`. Provider messages stay machine-oriented and unchanged. + +In `edit.ts` the `fs/edit-intent` waterfall now sits inside the same `try` as the provider mutation, so the policy plugin's `FS_NOT_OBSERVED` refusal thrown from the intent slot also receives the remedy — both refusal paths reach the model with the same recovery wording. + +## Alternatives considered + +- **Append the remedy to the provider messages in `dsh-fs` / `dsh-fs-local`.** Rejected because those messages are machine-oriented seam vocabulary consumed by retry, permission, and UI layers as well as the model surface; model-facing wording belongs at the model boundary, where `dsh-tool-fs` already owns result formatting ([filesystem capability seam](../architecture/2026-06-17-filesystem-capability-seam.md)). +- **Add the recovery to prompt guidance instead.** Rejected because the failure arrives mid-task; a static instruction does not reliably reach the retry decision, while the error message is present exactly when the model must act. +- **Signal the remedy with a new `FsError` code.** Rejected because the two failures are the same conditions retry layers already handle; splitting the code would fork routing on identical semantics. + +## Consequences + +Model-visible text for the two codes changes; the `fs-policy-reject` keyless snapshot is re-recorded, and the READMEs of `dsh-tool-fs` and `dsh-fs-policy` pin the exact appended text. Unit tests cover the wrapper directly (remedy text, code preservation, cause chaining, passthrough of other codes and non-`FsError` values) and the assembled tool paths assert the remedy reaches the model for both codes. + +The remedy is not a promise: a deleted observed target cannot be unblocked, because re-reading a missing file fails with `FS_NOT_FOUND` and records no observation. That dead end is pinned fail-closed in the integration tests — the retried mutation fails identically until the target exists again and is freshly observed. diff --git a/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.zh.md b/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.zh.md new file mode 100644 index 0000000000..11acd0cf48 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.zh.md @@ -0,0 +1,32 @@ +# Agent Note: Guarded-mutation errors append the recovery instruction at the model boundary + +Status: implemented + +[English](2026-08-03-fs-tool-error-remedy.md) | 中文 + +## Problem + +受防护的 `write` 与 `edit` 失败以只陈述条件、不给出唯一正确恢复方式的消息到达模型:`FS_STALE_VERSION`("file changed since it was read")与 `FS_NOT_OBSERVED`("edit requires reading … first")。模型必须自行猜测恢复方式是重新读取(或首次读取)后重试,而基于结构化错误码路由的重试/权限/UI 层看到的也是同一段消息文本。提供方拥有的消息属于存储接缝的面向机器词汇([filesystem capability seam](../architecture/2026-06-17-filesystem-capability-seam.md)),因此恢复指令不能放在那里,否则会把面向模型的措辞泄漏给 `FsError` 的每个消费者。 + +## Decision + +`dsh-tool-fs` 拥有一个面向模型的错误包装 `remediateFsError`(位于 `src/error.ts`),在 `write.ts` 与 `edit.ts` 中于沙箱拒绝映射之后应用。它为两个受防护变更错误码追加恢复指令,其余错误原样透传: + +- `FS_STALE_VERSION`(包括缺失的编辑目标——它与陈旧错误共用同一错误码)追加 `— re-read the file, then retry`。 +- `FS_NOT_OBSERVED` 追加 `— read the file, then retry`。 + +结构化 `FsError` 错误码保持不变,使重试/权限/UI 层继续基于它路由;原始错误作为 `cause` 链入。提供方消息保持面向机器且不变。 + +在 `edit.ts` 中,`fs/edit-intent` waterfall 现在与提供方变更位于同一个 `try` 内,因此策略插件从 intent 槽抛出的 `FS_NOT_OBSERVED` 拒绝也会获得恢复指令——两条拒绝路径都以相同的恢复措辞到达模型。 + +## Alternatives considered + +- **在 `dsh-fs` / `dsh-fs-local` 的提供方消息中追加恢复指令。** 被拒绝:这些消息是面向机器的接缝词汇,除模型表面外还被重试、权限与 UI 层消费;面向模型的措辞应位于模型边界,即 `dsh-tool-fs` 已经拥有结果格式化之处([filesystem capability seam](../architecture/2026-06-17-filesystem-capability-seam.md))。 +- **改为在提示词引导中加入恢复方式。** 被拒绝:失败发生在任务中途;静态指令无法可靠地影响重试决策,而错误消息恰好在模型必须行动时出现。 +- **用新的 `FsError` 错误码表达恢复指令。** 被拒绝:这两种失败本就是重试层已处理的相同条件;拆分错误码会让语义相同的路由分叉。 + +## Consequences + +两个错误码的模型可见文本发生变化;`fs-policy-reject` 无密钥快照被重新录制,`dsh-tool-fs` 与 `dsh-fs-policy` 的 README 逐字固定追加后的文本。单元测试直接覆盖包装器(恢复指令文本、错误码保留、cause 链、其他错误码与非 `FsError` 值的透传),组装后的工具路径断言两个错误码的恢复指令都到达模型。 + +恢复指令不是承诺:已删除的观察目标无法被解除阻塞,因为重新读取缺失文件会以 `FS_NOT_FOUND` 失败且不记录观察。这一死胡同在集成测试中以 fail-closed 方式固定——在目标重新存在并被新鲜观察之前,重试的变更以相同方式失败。 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 12/67] 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 13/67] 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 14/67] 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 15/67] 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 16/67] 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 17/67] 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 18/67] 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 19/67] 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 20/67] 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 21/67] 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 9db4372af80230b9c4be533d068bb07005eddd6b Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Thu, 6 Aug 2026 16:15:58 +0800 Subject: [PATCH 22/67] 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 ccb0842cfcc23ca11a89c136761e355eb0c94741 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Thu, 6 Aug 2026 16:27:27 +0800 Subject: [PATCH 23/67] 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 24/67] 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 25/67] 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 26/67] 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 318142ebe9aaef1bcf27c3949f8f59b1557c21e4 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Thu, 6 Aug 2026 19:21:04 +0800 Subject: [PATCH 27/67] docs(user): add a model-provider configuration guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guide tier said how to compose plugins with `cordis.yml` but never how to reach a provider other than DeepSeek, so the two things a person actually does — give a catalog provider its key from the Models page, and declare a gateway the installed catalog does not ship — had no home outside package READMEs. The new page covers both entry points and the relationship between them: the Models page and `$DSH_HOME/settings.yaml` write one document, over a `llm-pi-ai` adapter that mounts dormant until that document names routes. It carries the settings shape, catalog replacement and its capacity fallbacks, credential references, and the four failures a misconfigured route produces, and links the generated config catalog for exhaustive fields. It sits between Quick start and Configuration in the guide sidebar, which is where a reader hits the question. --- docs/user/guide/providers.i18n.yaml | 6 ++ docs/user/guide/providers.md | 113 +++++++++++++++++++++++++++ docs/user/guide/providers.zh.md | 113 +++++++++++++++++++++++++++ docs/user/guide/quickstart.i18n.yaml | 4 +- docs/user/guide/quickstart.md | 1 + docs/user/guide/quickstart.zh.md | 1 + website/docs.ts | 10 ++- 7 files changed, 245 insertions(+), 3 deletions(-) create mode 100644 docs/user/guide/providers.i18n.yaml create mode 100644 docs/user/guide/providers.md create mode 100644 docs/user/guide/providers.zh.md diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml new file mode 100644 index 0000000000..04b27adb51 --- /dev/null +++ b/docs/user/guide/providers.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/user/guide/providers.md +providers.md: 2ae1093699d8eb26171a2403db155113d84e437e +providers.zh.md: 6ce513c659140ed18716bd5c8f75c428ad981f2b diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md new file mode 100644 index 0000000000..2ae1093699 --- /dev/null +++ b/docs/user/guide/providers.md @@ -0,0 +1,113 @@ +# Configure model providers + +English | [中文](providers.zh.md) + +Harness ships with DeepSeek and mounts a generic multi-provider adapter alongside it, for the providers in pi-ai's installed catalog — Anthropic, OpenAI, and the rest — and for any OpenAI-compatible gateway or self-hosted server. You have two entry points: the **Models** page in the web UI, and `$DSH_HOME/settings.yaml`. Both write the same document, and a change takes effect on the next request without a restart. + +## Where providers come from + +`cordis.yml` decides which **adapters** are installed; the settings document decides which **providers** run. The shipped composition carries two LLM adapters: + +- `llm-deepseek` serves the `deepseek-official` route, the one available out of the box. +- `llm-pi-ai` mounts **dormant**: zero routes and no extra entries in the model picker until an `llm-pi-ai:` settings section supplies provider profiles, at which point those routes register live and drop again when the section empties. + +Adding a provider therefore rarely means editing `cordis.yml` — writing settings is enough, and that is exactly what the Models page does. + +## Configure from the web UI + +Start `pnpm run dsh web` and open **Settings → Models**. + +**Give DeepSeek its key.** The DeepSeek card carries one API-key field; fill it in, save, and the provider is ready. + +**Add a provider from the installed catalog.** Choose **Add provider**, pick one of pi-ai's catalog providers (anthropic, openai, and so on), and enter that provider's API key. The endpoint, protocol, and model catalog all come from the catalog; the key is the only thing you owe. + +**Add a custom provider.** Choose **Add a custom provider** for a route the catalog does not ship — a company gateway, a self-hosted server, or a provider newer than the installed catalog. It asks for a Provider ID (the lowercase identifier that names the route in requests and as its credential), a base URL, a protocol, and at least one model. + +**Let the endpoint report its models.** Expand **Model catalog** and choose **Fetch available models**: the interrogation asks the endpoint **the form currently shows** — including a base URL edited but not yet saved and a key typed but not yet stored — and offers what it reports as candidates to pick from. A route the installed catalog describes is answered from that catalog with no network call. Adopting a candidate only writes rows into the draft; nothing is stored until you save. + +Keys are write-only: the page only ever holds a redacted descriptor, never the literal secret. A key you enter is stored in `$DSH_HOME/.env`, and the profile records only the variable name that references it. + +## settings.yaml for advanced configuration + +The document lives at `$DSH_HOME/settings.yaml` (`$DSH_HOME` defaults to `~/.dsh`). The Models page writes this file, and you can edit it directly; neither source outranks the other. + +```yaml +llm-deepseek: + reasoningEffort: high + +llm-pi-ai: + providers: + # Catalog route: endpoint, protocol, and models come from pi-ai; you supply + # the credential. + openai: + apiKeyEnv: OPENAI_API_KEY + + # Also a catalog route, moved to a private proxy, with its catalog narrowed + # to one model and that model's capacity corrected. Every unset field still + # comes from the catalog. + anthropic: + apiKeyEnv: ANTHROPIC_API_KEY + baseURL: https://proxy.example.com:8443 + reasoning: high + models: + - id: claude-sonnet-4-5 + contextWindow: 200000 + + # Hand-declared route: pi-ai ships nothing under this key, so the profile + # supplies the whole provider. + acme-gateway: + displayName: Acme Gateway + apiKeyEnv: ACME_GATEWAY_API_KEY + api: openai-completions + baseURL: https://gateway.acme.example/v1 + models: + - id: acme-large + name: Acme Large + contextWindow: 65536 + maxTokens: 4096 +``` + +A settings section merges over the matching `cordis.yml` configuration **per provider**, so you can override one field of one route and leave the rest as the composition set them. + +A profile the adapter could not serve is refused **where it is written**: a hand-declared route needs `api`, `baseURL`, and at least one model, and a profile missing any of them fails naming the offending route and model rather than being stored and quietly disabling the whole namespace. When an already-stored document is broken by an external edit, settings keeps the last good value and warns. + +## The model catalog + +A profile's `models` list *replaces* that route's installed catalog rather than extending it; omitting it or leaving it empty serves the catalog unchanged. Each entry defaults its unset fields from the installed model of the same `id`, so narrowing a route to two models, correcting one capacity, or adding a model newer than the installed catalog are each a one-line edit. + +Only the four fields the harness consumes are configurable: `id`, `name`, `contextWindow`, and `maxTokens`. Pricing and input modalities have no consumer, and reasoning is not per-model configurable at all — it rides the installed catalog entry. + +A model neither the entry nor the catalog sizes takes the route's `defaultContextWindow` (262,144) and `defaultMaxTokens` (32,768). Both are guesses by construction, which is why they are route fields: a deployment whose gateway serves smaller models corrects them once. + +Model ids are not lifecycle configuration. Requesting a model the route does not configure fails with `UNKNOWN_MODEL` before any provider request goes out. + +## Credentials + +Prefer `apiKeyEnv`: it is a *reference* resolved per request, so no secret enters the configuration file. A literal `apiKey` is the escape hatch. Omitting both is what leaves a route unauthenticated, which for a catalog route means pi-ai's own environment discovery. A reference that resolves to nothing fails the request with `MISSING_CREDENTIAL` rather than falling through to whatever unrelated key the environment happens to hold. + +References resolve from `$DSH_HOME/.env` — what the Models page's key fields write — and from the matching environment variable when no credential service is mounted. One credential serves every model on its route. + +## Point an agent at the new provider + +A configured route appears in the web model picker and can be switched at any time. To change the default, edit the `agent-loop` entry's `provider` and `model` in `cordis.yml`: + +```yaml +- id: agent-loop + name: '@deepseek-ai/dsh-agent-loop' + config: + agents: + - id: main + provider: acme-gateway + model: acme-large +``` + +## Troubleshooting + +- **`MISSING_CREDENTIAL`** — the variable the profile's `apiKeyEnv` names holds no value. Store the key once through the Models page, or export the variable. +- **`UNKNOWN_MODEL`** — the requested model is not in the route's configured catalog. Add it to `models`, or use an id the catalog already carries. +- **`settings-rejected`** — the written profile cannot be served, and the message names the route and model. For a hand-declared route, check that `api`, `baseURL`, and `models` are all present. +- **Fetching available models answers 401** — the endpoint refused the interrogation. Check the key; if the base URL points at an Anthropic-style gateway, note that the interrogation reads only the OpenAI-compatible `GET /models`, so enter the models by hand instead. + +## Exact field reference + +The complete fields, types, and defaults each plugin currently supports live in the generated [plugin configuration catalog](../../config-catalog.md). Each adapter's own semantics belong to its README: [`dsh-llm-pi-ai`](../../../packages/llm/llm-pi-ai/README.md) and [`dsh-llm-deepseek`](../../../packages/llm/llm-deepseek/README.md). For `cordis.yml` itself, see [Configuration](./config.md). diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md new file mode 100644 index 0000000000..6ce513c659 --- /dev/null +++ b/docs/user/guide/providers.zh.md @@ -0,0 +1,113 @@ +# 配置模型提供方 + +[English](providers.md) | 中文 + +Harness 出厂就带 DeepSeek,同时挂着一个通用的多提供方适配器,用来接入 Anthropic、OpenAI 这类内置目录里的提供方,或任何 OpenAI 兼容的网关与自建服务。你有两个入口:Web 界面的**模型**页,以及 `$DSH_HOME/settings.yaml`。两者写的是同一份文档,改完下一次请求即生效,不用重启。 + +## 提供方从哪里来 + +`cordis.yml` 决定装了哪些**适配器**,settings 文档决定跑哪些**提供方**。出厂组合里有两个 LLM 适配器: + +- `llm-deepseek` 提供 `deepseek-official` 路由,是默认可用的那个。 +- `llm-pi-ai` 以**休眠**状态挂载:零路由,模型选择器里也不会多出条目,直到 settings 里的 `llm-pi-ai:` 段落给出 provider profile,路由才注册上来;段落清空则一并撤下。 + +因此新增一个提供方通常不需要改 `cordis.yml`,写 settings 就够了——而模型页做的正是这件事。 + +## 在 Web 界面里配置 + +启动 `pnpm run dsh web`,打开**设置 → 模型**。 + +**填 DeepSeek 的密钥。** DeepSeek 卡片上只有一个 API 密钥输入框,填好保存即可开始用。 + +**添加内置目录里的提供方。** 点**添加提供方**,从 pi-ai 内置目录中选一个(anthropic、openai 等),填入该提供方的 API 密钥。端点、协议和模型目录都由内置目录提供,你只需要给密钥。 + +**添加自定义提供方。** 点**添加自定义提供方**,用于内置目录没有的路由——公司网关、自建服务,或比内置目录更新的提供方。需要填 Provider ID(请求里点名它、也作为凭据名的小写标识)、API 地址、协议,以及至少一个模型。 + +**让端点自己报模型。** 展开**模型目录**后点**获取可用模型**,会按你**当前表单里**的地址与密钥去问端点(地址改了但没保存、密钥刚输入还没存下,都算数),把它报告的模型列成候选让你勾选。内置目录里的路由直接由目录作答,不联网。采纳只是把行写进草稿,最终还是你点保存才落盘。 + +密钥是只写的:页面拿到的永远是脱敏描述符,不是明文。写入的密钥存进 `$DSH_HOME/.env`,profile 里只记录引用它的变量名。 + +## settings.yaml:进阶配置 + +文档位于 `$DSH_HOME/settings.yaml`(`$DSH_HOME` 默认是 `~/.dsh`)。模型页写的就是这个文件,你也可以直接编辑它——两个来源没有主次之分。 + +```yaml +llm-deepseek: + reasoningEffort: high + +llm-pi-ai: + providers: + # Catalog route: endpoint, protocol, and models come from pi-ai; you supply + # the credential. + openai: + apiKeyEnv: OPENAI_API_KEY + + # Also a catalog route, moved to a private proxy, with its catalog narrowed + # to one model and that model's capacity corrected. Every unset field still + # comes from the catalog. + anthropic: + apiKeyEnv: ANTHROPIC_API_KEY + baseURL: https://proxy.example.com:8443 + reasoning: high + models: + - id: claude-sonnet-4-5 + contextWindow: 200000 + + # Hand-declared route: pi-ai ships nothing under this key, so the profile + # supplies the whole provider. + acme-gateway: + displayName: Acme Gateway + apiKeyEnv: ACME_GATEWAY_API_KEY + api: openai-completions + baseURL: https://gateway.acme.example/v1 + models: + - id: acme-large + name: Acme Large + contextWindow: 65536 + maxTokens: 4096 +``` + +settings 段落**逐个提供方**地盖在 `cordis.yml` 的同名配置之上,所以你可以只覆盖某个路由的一个字段,其余保持组合里的样子。 + +一份服务不了的 profile 会在**写入处**被拒绝:手工声明的路由必须给出 `api`、`baseURL` 和至少一个模型,缺了会带着路由名和模型名报错,而不是存下来再让整个命名空间静默失效。已经存好的文档被外部改坏时,settings 会保留上一次的好值并告警。 + +## 模型目录 + +`models` 是**替换**该路由的内置目录,不是往里追加;省略或留空则原样使用内置目录。每个条目会从同 `id` 的内置模型继承自己没写的字段,所以「收窄到两个模型」「更正一个容量」「加一个比内置目录更新的模型」都是一行编辑。 + +可配置的只有 harness 会消费的四个字段:`id`、`name`、`contextWindow`、`maxTokens`。定价与输入模态没有消费方,推理能力也不按模型配置——它随内置目录条目走。 + +两处容量都没给出的模型,取路由级兜底 `defaultContextWindow`(262144)与 `defaultMaxTokens`(32768)。这两个数按定义就是猜测,所以它们是路由字段:网关服务的模型更小时改一次即可。 + +模型 id 不是生命周期配置:请求一个该路由没有配置的模型,会在任何网络请求之前以 `UNKNOWN_MODEL` 失败。 + +## 凭据 + +优先用 `apiKeyEnv`——它是一个**引用**,每次请求时解析,密钥本身不进配置文件;`apiKey` 字面量是应急出口。两者都不给,才表示这个路由不带认证,对内置目录路由意味着交给 pi-ai 自己的环境发现。给了引用却解析不到,请求会以 `MISSING_CREDENTIAL` 失败,而不是退回去用环境里碰巧存在的某个不相干的 key 计费。 + +引用解析自 `$DSH_HOME/.env`(模型页的密钥输入框写的就是它),没有挂载凭据服务时则直接读同名环境变量。一份凭据供该路由上的所有模型使用。 + +## 让 agent 用上新提供方 + +配好的路由会出现在 Web 的模型选择器里,随时可切。要改默认值,就在 `cordis.yml` 里改 `agent-loop` 那条的 `provider` 与 `model`: + +```yaml +- id: agent-loop + name: '@deepseek-ai/dsh-agent-loop' + config: + agents: + - id: main + provider: acme-gateway + model: acme-large +``` + +## 排错 + +- **`MISSING_CREDENTIAL`** — profile 里的 `apiKeyEnv` 指向的变量没有值。用模型页存一次密钥,或导出该环境变量。 +- **`UNKNOWN_MODEL`** — 请求的模型不在该路由配置的目录里。把它加进 `models`,或改用目录里已有的 id。 +- **`settings-rejected`** — 写入的 profile 服务不了,错误信息会点名具体的路由和模型。手工声明的路由检查 `api`、`baseURL`、`models` 是否齐全。 +- **获取可用模型返回 401** — 端点拒绝了这次探测。检查密钥;若地址指向的是 Anthropic 风格网关,注意探测只读 OpenAI 兼容的 `GET /models`,此时手工填写模型即可。 + +## 精确字段参考 + +每个插件当前支持的完整字段、类型与默认值见自动生成的[插件配置目录](../../config-catalog.md)。两个适配器各自的语义由它们的 README 负责:[`dsh-llm-pi-ai`](../../../packages/llm/llm-pi-ai/README.md) 与 [`dsh-llm-deepseek`](../../../packages/llm/llm-deepseek/README.md)。`cordis.yml` 本身的写法见[配置文件](./config.md)。 diff --git a/docs/user/guide/quickstart.i18n.yaml b/docs/user/guide/quickstart.i18n.yaml index 2cf494f71a..74fd06f83d 100644 --- a/docs/user/guide/quickstart.i18n.yaml +++ b/docs/user/guide/quickstart.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/quickstart.md -quickstart.md: 199b3f092159fa6fbaf3ae298151487c924ac6f1 -quickstart.zh.md: 9327ed646ba211bcce6426beb6bf76fca50acbf6 +quickstart.md: 8a9ed716d9395448aadfb97d0935bd42ee06e6c1 +quickstart.zh.md: 3652b0453f870640b278ce6f1355e67e85983ffe diff --git a/docs/user/guide/quickstart.md b/docs/user/guide/quickstart.md index 199b3f0921..8a9ed716d9 100644 --- a/docs/user/guide/quickstart.md +++ b/docs/user/guide/quickstart.md @@ -57,5 +57,6 @@ headless-agent uses the `@deepseek-ai/dsh-cli-demo` app. `dsh web` instead compo ## Next steps +- [Model providers](./providers.md) — reach providers beyond DeepSeek, and custom gateways - [Configuration](./config.md) — understand the `cordis.yml` format - [Develop a plugin](../develop/basic/) — build your own tool or backend diff --git a/docs/user/guide/quickstart.zh.md b/docs/user/guide/quickstart.zh.md index 9327ed646b..3652b0453f 100644 --- a/docs/user/guide/quickstart.zh.md +++ b/docs/user/guide/quickstart.zh.md @@ -57,5 +57,6 @@ headless-agent 使用 `@deepseek-ai/dsh-cli-demo` app。`dsh web` 则组合 [`ap ## 下一步 +- [配置模型提供方](./providers.md) — 接入 DeepSeek 之外的提供方与自定义网关 - [配置文件](./config.md) — 了解 `cordis.yml` 的格式 - [开发插件](../develop/basic/) — 编写自己的 tool 或后端 diff --git a/website/docs.ts b/website/docs.ts index 8d42ae3209..2b9c4654c4 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -130,13 +130,21 @@ const homeAndGuide = pairedPages([ section: { root: '入门', en: 'Guide' }, order: 2, }, + { + source: 'docs/user/guide/providers.md', + route: 'guide/providers.md', + label: { root: '配置模型提供方', en: 'Model providers' }, + sidebar: { root: 'zh-guide', en: 'en-guide' }, + section: { root: '入门', en: 'Guide' }, + order: 3, + }, { source: 'docs/user/guide/config.md', route: 'guide/config.md', label: { root: '配置文件', en: 'Configuration' }, sidebar: { root: 'zh-guide', en: 'en-guide' }, section: { root: '入门', en: 'Guide' }, - order: 3, + order: 4, }, ]) 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 28/67] 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 a231b56eba22681e0015b6339f2116381584308b Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Thu, 6 Aug 2026 19:58:31 +0800 Subject: [PATCH 29/67] docs(user): show the Models page in the provider guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page told a reader to open Settings → Models and named the two buttons, which is thin help for someone who has never seen the page. Two screenshots per language carry it instead: the Models page with its provider card and both add actions, and the custom-provider form with the fields it asks for. They are the first images under docs/. The projector rewrites a repository-relative image to a raw.githubusercontent URL pinned at the built commit, so nothing is copied into the site bundle, and the pairing gate takes no signature from image nodes — which is what lets each language carry its own localized capture. --- docs/user/guide/providers-custom-form.png | Bin 0 -> 58692 bytes docs/user/guide/providers-custom-form.zh.png | Bin 0 -> 57720 bytes docs/user/guide/providers-models-page.png | Bin 0 -> 75818 bytes docs/user/guide/providers-models-page.zh.png | Bin 0 -> 70021 bytes docs/user/guide/providers.i18n.yaml | 4 ++-- docs/user/guide/providers.md | 4 ++++ docs/user/guide/providers.zh.md | 4 ++++ 7 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 docs/user/guide/providers-custom-form.png create mode 100644 docs/user/guide/providers-custom-form.zh.png create mode 100644 docs/user/guide/providers-models-page.png create mode 100644 docs/user/guide/providers-models-page.zh.png diff --git a/docs/user/guide/providers-custom-form.png b/docs/user/guide/providers-custom-form.png new file mode 100644 index 0000000000000000000000000000000000000000..bbbedde794dcf1f5b55fc7b9418ff7bca3e0cfde GIT binary patch literal 58692 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�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 VNAUC%3!fJ?5@YXdS7&o3`JaP0h|&N6 literal 0 HcmV?d00001 diff --git a/docs/user/guide/providers-custom-form.zh.png b/docs/user/guide/providers-custom-form.zh.png new file mode 100644 index 0000000000000000000000000000000000000000..2c4812033145261e312c3e79348c38a4cd3d514b GIT binary patch literal 57720 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 LVLc;}}Q+v*3hLcuR#J#;$0B(|`}t&U&%c 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�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�)jwp);+2!aY8P>`UA<b23EC?XU{0afIv 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 V2}{D{Yioca44$rjF6*2UngBln29f{( literal 0 HcmV?d00001 diff --git a/docs/user/guide/providers-models-page.png b/docs/user/guide/providers-models-page.png new file mode 100644 index 0000000000000000000000000000000000000000..f3ffe6e90f6768c31a6816ff7728488eff70a893 GIT binary patch literal 75818 zcmdqJWmr^e`#y{TZb3k$q&F(vEjb7R(jAgYH_|YqA|N0j-QA6ZbVzr14Glwg!~AbV z_w)PyetXw(@P(PR*1gt!<$0dh_{vI)qN5U^A|WB6i@kj#kA#FGfP{2U=byXaKR;PQ zOpuTsBZ<9vt>_rHIfLwiWk~v9H*>pSSOU#!P|(U}VSAf}L$A?Aw|kriPfrL%x1zeb z8k<TfPh@6h>xvR{S}-J{R7%1F=J1YRhB2~h+1g?0Y}qMch}Z7?Q7-cG@^Xm2JjH7n znX&%<{)UEz_V#uGZEXp4b$=t1^|du7u!!G>;{YlenuNHxU(5dfzIAh!tgLJj!)vK; z)I#K41A5}*(dth+bHrQN*x9AN1@v@F_Vo17)6uc9u|4~Hg)ACk<_UP%L7K0=fq{X( zzFfQT4@_Y`Y=a*$$*S2Yk0gxmL@0*R`W399%XW;8j!v{p3^sng^XK#iiNInSTIOp* z6O;ADwjeAlEb4}om;U7)IwLIy<P+q>Yz-kM*23=c7@`B=b#+>vDBIG3fu-w@{@z|o z7+0sIr3G8JrL}csdD+<5Sgt*N`k6%jr0)BUfC3}CfPeskit4F}3Fg12IL0@(wfT5? zEiWw%^S0_d#aFhA5$zCB(bv}(78b@`SwSbLK$B|!`}Mgpc^Nu#^5ub4QG9&-$;nA} zxFQu>OOBccIgTdt9@7@MJ@@3~)>K(pX{p}dvv#2+*b-7w=>PmfMMJZYT2r$A6n(qE z5l^tv6~$Nm>YRiGJ>0J4&EKy(vdg+0?#?%T{rXSB2PGbU`7C5*Vf4boqeHcF3w<M_ z|5k&PAndEu<c+?ywPiU|r3AJsJw3gkpf_imtB)ElG)7fTO-)g8X>D$9V+se3^Y{9S zjSUS+Hmkk%_V(CRa(T@;1;x0k#RE7y9B)tZ)qWP0mFfTeDpFr+Mh1A5;C0|Ud-k?N zUa1<Z6SGJ!m6rcUTG|^4g%aI13@8-(-xdgb;Naj85fS0En5JT27#$px=WPlZ@+wL> z%*o1XZ|^TM9b<S~`1kTj1cygQJocM!=bs`=S+i1Arro2f?4zTlrKO`QEiPUk|8cKH z_rGf=sZnXH_a}R%r<1oxp&F<nkX5bQObunGv?fM!$1?14O|&;3|Gl(_f{KdeKnk+H zD(cTB=+pN~Y_u|k-QAM0oF^ateg{ch5I@4#SIo+4&xVyw&O=EIsw6M3qo+4sV6I+T zd*hwI5Au|XY^7XfZf<UD0VV!SDESi=W8>_M41){%t^aHO9`bT)!v&f=s7W+^MZ>hv zq;Fvn_C_#_)SvhMTuFd_V06@Crphi`RrsJCvxusOhKkB)VWhdC!Q-zRAPp0sCJ}I2 zXsD{b4Gx>eU%IdAvX%=yGK-Io`_Iz^=xuCmOMm=Gek;Chq%wF1x94SNRcBZ1#ayxH zpL-$6iq_QDIx&&Qe&EA;AMTHXgTrny{Z>IC^nZ7HNKc>djZP@f)PhCR;d6~vbb1e% zP-|;zK|#U)-aAHRMTIO=i>OUxcToaZ&C{77q8`_n|D4E!dv$9|Q(nGN)CP}*&aMMO zRa^YyM@EwW|8B(f;>8P7Q`6ah{P_a%hrJH{P=TkAkdXX$lR0GL{-jU+|M>f|<|0}f zQuMk82OG!Rzy5cLnty>=Wn^RsdbGT3Yk10DZ!-S<e;>+jZ*P;6llOi5Buq8L!p{Dq zsObIQ_khG079Jk{=@Yp6hM0$NbVRD>y}uiRBzq??Fc4dHZ+|HFaYW>hBFf*hlJ0J9 zZFMz2!z_B!RIdKc#KeT<??*^Dz|r(AEUsTR&@eJGQd3(Mi6UPA{Zy6=0|Uc_%0bRU zczvYgf38H+G&&kVsj?1yiGqTHfPmn?XBW_yeXnn3COIDt1$O0C$@Rb0F@rdYt>s(w zSnb`r|8sv`W8-(q${AvJaKryI2@gN;&`Hixy1FkOc_9B?4b4{_S|K4JseAYC{du&c zqUS#b2M3FbS={=VJyask6~Z!ioz0n1%7PkcrT8@Y{(MiCx-%0b1T<9CHIjEYHQq}Z zrD1yT^>vUk`fFoi2s|fW$EpmqkN@2wMGu;-iHV8I%F0G;baZq%S9t{mBR|2DtlFmB z{F)jKBkQ*(V2i?5<;i5e88Bg|>VEoE4Byx=H8D2%YfAWvx_uoT$!mU`yvfbtGBQCa zwDk0fFXzjIp5%K+F|{-|r&UzEf<O{O=sHW*-Y38GZ&Q5~CN3%}s-RA|N__j!-$j)6 zHa9mx?4bcBM~IIvM;vm;<32sJuI^%Eqae4IpPyfGaWU0FI(67Fi|kWwWMh*LHGh`y zT~<pgAtFKq$C{!bQ9+4l07qciwiy)_m8zA7fk9eErmwG$la*CyOf5YLE11AsR#Q{+ z&kOrkx=>zTJ_|)!{{=G&l2R~zYx9<OcTW#9jdG71OO~>{{PT%VShs7`QU_)brX|N0 zoHGr!*vJuCme0$}3l4|d3K*N1sHv*{>zGY88DfH6z<PUz<CYu_GZ$B7X{n;Rdh8&r zV&xr^&lu#LMc`@HWWn<Y^5hW+{@r-Q5z+A27$-ZsK2p*n7bZGXFOaI4NR*}iMf@=? zId*W79D^6NcV(Zmva)jL-CXCfuI#;kivBFo0qiC+GO|k%2?~;UOIyPN9+jS~=6@fX z)@bDy6a+FzBb9x^pyjSud%P&d>}m4)&k}aeX=rJ?+S}hR1WG?EBTkAiGBFt>6G7&l zX2Nwk`nTaWx;nZpudK*>>%V`Wl$zRvlr-8;mQh$J<D)Jujp3Oi<dN<I;`6Pe2rNIe zGX|!Nf>hSs-u~5BL6@yD$2*d`frg%5N=7D=l}<rvZ|@;7&l!~McpdVd>AdXsJv{tU z5B+EcQBY9Uc>j@ouqKe&gdR|ovr7I?(eV}!lim3@6cQ=A{4I?c-nF{m%#*x{oj5L= zi0r7cjnM}_Uu5SyM@*ehwhV9I3Jo12V^H!LjsWq3kGh2gL#|M@{no>O9_c9AUY(;u zpDW<UNZwQvy-pW1_a0!1NvCD8kdawU9TVHe?zJ8!x?LtlMBpl*a64SU(C73EDL^e1 zptoA-j(BaNMt~zJ`7PEFD$ZwOd<MfY%%`Kr|9xAJ92z>hwDoWhmWyMw^$qm-+%8{H zGk0`OPIhz*3^#xI)VQ#S#bG(IzT?ix!*k(7T9?XgY;WJ2H({yZb+Usa0oT=G%xp+X zl7YmpZcH7O$UqDjKW}=c`@-B#YGG3K5I4j~Qz}`a`FslbFEX;7x`UrGB^h}f1M?z@ z2CrYgmXizq`I95p6^~Iff$PHjg;3Mh6i2!Fl*lhbIVii!(la#~LiO0*cy1P)q^dj0 zZ{9SY`YH$t?TASa4-Iwo_2FG>T<s8WKN~H)8XFz;9#PfPQ$*bHbUIrL_5|fpc>Jml zsr$+&wK0bHlbtF0qqS43#m}BX(((MPI;TY>d{uwkK&i!tPfnc0dVV3jK7xVAX}@&o zO%7HqtD$naMctzWAHfK>dnlqJA}j+;V&2)=DF#@Ey$O6SOH?=%QbdS-WhPFuahD?k zFEUP;lk?9{pYG(UTXUMJsZB1lxXXXoI3|X<U6CgqG<x~3_G&-l(s49@pepIokMngz z$|F5LUAQnRI9lw}aDwI(PDha=G(0v|f;9SP$mFAuI6r$bI(m9yW|O?{T1Q($7wz-+ zg6A<8bW^vz?r+al=IeLiUXo4bu^RS#$obXZ|Gs0v>({4i82zb;lvKcND$AnLM^>%B zK5m;MYoe|l92!ck+WY>?b4^QI8*vC{Z4&u*J>QndtaW5#4y*GN#I<BBv@w(s7WH!2 z8jPl}-<oV0D>p6F;+rhDY*U<#W;G<_*iVtsFgZW9(v4w1a@(0H-{7y+!7)>{u{nsg z?AhHj<}g|Mc>H|Vv!EBHH)Ao_ns)LU`>h<EFtzydfkcK1DvFk!kg)oD;#VK3czHiZ zLe5T`&a_<^c=b$8g*dstt=1vtGkVmGJI~PYET=0bGNerZghNzN05Z_ikAOI=QYcsA zy-cfoeLBRb7{zV3Kc?;WdH^XgAvk!iDX9Lk<QxWb(|$WLcd}a7m89ds7HhTG`j!L1 z%+5SfZb<1YhK-8@QpZRroOq<BvhU0$a{}$TLsN*h+jVg5E8G5oWYM<wVq(4-2Aa(* zyiE%~6qmP(6gg^jXr}INWF(R@8W|aNNAoGzqr_Z~L>_pp*sBz1<QvI>5<kpKv&NR| z)ytQ(w6WQb&T*cz9g+}ByG%E)cJ}qvyCZ(hcnEYzhoSoU`qo0Xpw`wbxAF7w1J%6N zp2Fv;Dc2{pRR63^WuBx!+^_~|bxcgoHy9xpBy8V1Ay=2V{Q`HEQr3L7`3N}PCn?fY zf3UN)je1r1-cC5!UNt99i#go<Q~39&b~KFRV~`1~SAYEYF<WhD2!(Qo$H&L(89JWs z4h-sdmG6v`PcZe3m2XUy7#^*U2w6+REckY2++~%ODJ1b(Z0{q;8fRy>M|(_tFi4~K zR_G{xIBQloc8qR!_%mo7UW_xR*oUYTXc1nQ{?hw6M~voAsaxe<J?HLTc<F;mug(T> zF@gvi*I%EvrFW@-<bC$2CkD!>cIxbp+p}CZ6w7&q%b;d`IBs>F855(Ht4!~Hy>}5o zuWmM7x*RGa+%p7%*N%_4w!+->8Jd6OCC=l=Q4}PO399ejv74`7&3rD@7fpI_6VE)x zM2(H9XU2B@R5!*fQ($9+Y#+7Dti3k>rD4-=*{>(zb#dI8y#fIku~Th%W}7pYckPfL zBvUd`GP{3LFJYJNe6kH+hnJa5=p5%oD<X%wlBqR9a>wo?Kgi9^w%eLakF`|E!4eDP zOAL-an=sT~BV1DmTv>J4X!s>`h*eh*7#OJ%iaG1vH#r$Yom5oxacrB<2ZPwx*SG9P znep~eo=P8y^~L!cJ~x-^%NBn;MlUa)a`T}oh0H)(R%VWfIK!cpe72^jffrNdmgU9= z5nU<;-acMq4!ID=xrX^BAJh`ptDeuGUU`5?DtFdbAF(w~O<;0XjSQ3ZiZm&<fg!%m zuI%f(i!(&$qyuZ@b6ufRlG3>8c5bl%n*97c=*c#hUFT9m(i{%0G8D>@a52l{@^g_= zdV8Xz(AfdToGL2DA3san5?Ja2#mSag%IAcQS6CfXqTv~NGG3VaGTHWBU10vSm?C*f z5EJ!%JWK9Hj-qtjD=o+RJD*;6MdW|=_s6n~UW+c7&G@`q<#6wABqO5QYEeW!f7i%# z$nZ_<*Pq`iqu;H9!0xc!E~<+6cAMtnnDmvy_GLWJ_2Gc6(U?VV+`Dkhf0a7C^%v&z zNg;vPhoM2!)9WSTd=9W&61O$6jK_9K`}V3d30hCsM>Yc_f1r8s*SM^X+Nr3hC<|kA z)t-%R`Vr99o@`IS+z!I~m4CXnG03O3pb-&mdfwgMgnaziIn1ew9C2NyxA4VEG3kZn zToR>WiYTTaU82_4kJpjfjOy2%I7XtdA`7X$S=Ysdg>+m&+7dH(h?Q>JTPf}O4?_<~ z-RK<3Gsfttshtp>L0OWkfm!XMjY5Y%u77;A60Yr`-nhDUht+YVHagt@opcL-e*5;V zriM4CWLWPR95;W`+eg&pYUC0V5~V)C8%8V}Hn*_3>Ap&lSZq3$fN$AD&|k0JM=}FB zUBjXXta9vWnV!DfjK=Df?qidEWwWxG#k}aWKoP}eb*b&nfO{>O^uA>%Hb;1>+?<5P z?4{QW#ioT3Dg}k17e2=d#Z5Fy8XCOT!{Y~d4B`ctICW<x*B3kWlw7MFp_y%giS1*e zQK7SQzKcHOp)5Js`Ret17YO<a=+Wf-3sJ8lG6f15jbg{-C5|vP{CYx_nVFg55*woU z4F%<pm`m-eU@eE~FJhF^aUpT_x<1E>^|9QpA|Qo2JM7G1;W1txOzqSqo{c`r-nI^F zv0b0ecXJ(^avM9tp2te23!zzbc~0+x*-*cpIHK)Z>%oz|bdfG#G8SmgYo3;7);<1E z-tx8MOln(T5Fw<a+%(eF+2LW3JPN1I(lgY;mysRKcd$lAy(Yfkq(0v5Dy$3EaKn=6 zQ=kl5hr@rMDi(jp?jI?CKyKO=NMv?$xNa`w6Ts_m-8f25cyYA#qPwuYPKB?OPeDO} znuaC&z~8PDJx3986Yjla_fb7SnsnNoBg;kY)Tfk6XdR;EmXbA-Y!iU-*9LrhboR&= z<Lam!^5v}B9?ttwz@0yia4gxCCq}S5VZoGdxTI{^9W|1tw5VuKVQpn?EomJ3BWx*b zK(|!^Dl9|F;`o@@$KL+vRLGVg7O~8ESp8|~GE+J};o{n-(KzthIJv3Lu#e=D>S}FK z2Ea1pCthyX%jG-nr@o2EYqkBB#Iy)44%c%<66C?;{f^87Dhu7;=C-T0V7DLh$Rj*D zyyf0qklDozOxNk5Y%Db5Yj}+_dnbsc5~rOSzKhGsAuHzKY6pu{0{kA<4~^Iza>{~& zf|-)ep6hRJ$`@pY!;-6vfDPb2oEDRfj4vsvDLEZBD|{1O&zZgS#U3)R_Qs{zrDkm( zI32u8&st|&-8!MFP0=uznH-UO5@exKE&0K(u8v<{z-3o|8&t`q{G~^0N(`F3grtOJ zmVLdmwa$ZtX;oU%(gnjIcnoS!agI(KuK5m0UNmmIU-YO*PD|dD%`V>lt9@@oy$<ZP z2uBN(S!Sp-bBvT$UH5R(Rg{&LX(JGJ5Vp1Xt2rgw$e~>KlPk<T0|w2i%acxbT$hWZ zi6P3n_bkdyM*JuSMK8~47^yAKk0MEpb<Av=f9A(#1>zGxcZ94?-$HG7L^f&+j%a9{ z*^L`vpZjYhaLu=(OJEs?e#Zd;grg!NylH(KK`{4ZD1&}HkHc0(OybXxoq9%%iyg>( z(DndIW@c1$G_uc^%Q9Oha?8vdLS^Of<Jz1Bp9DoVWlp_|r*+tH9EAynFFmmrY$i)q zJF~7dpv?Ta(O)UCDlO!Lv3Im3_{)B|FFoLTb{f;kURPJwb>Z|0S$(FYYj=fjShD=t z2?ST}%bQF}8pLJjnrvomiBM*NOK)La3?qKZfV7M=4Gx7&wA<Vq83i{T-&q(<@(}6_ zqE-hUHIV9vrrsICe`Q7zUmta<mas8u{&-SpP>9s^GJ017HF-9QKU_NLGP(BRxG&*= zftahfP_V6`c$wDG2?WeA^n<Y!JbEgswUKJ~_usQh=Ic#I?bpXY{VqBiHZoW4D9h_S zWps2_!F})K#0STYh>Cj2^%nQ40Ajbkw1X)3Kun~Ho{Z+JU)b;9V-wYwoxu`aF3A%4 zoqKsECb%Fjh?yxHgFWhd4-N}=)+cji-2Eb21BrEMnN42RW=FHauwy*WPT5jFW`f9$ z$DjpjtHQLh-d{pK)SNd(UX_!lzdxE3GY5ysc+sce<oO4ruEo~NkeWX9=!0J8u)<oz zwetZ#B1{InW^tVBSyz-(7`p2Is&+`^zq$+au_v3ez3K)4oshTN=_N*7J-|qKWmW8X zI{Eo>(j{hr!5HQITrd}{avSDe1Y#BdMhV9XpTuAUkg$i){B*rQ*I6+8s)R=#f?X;% zqHe8YE7|S5QzykW_w`BBX;_^rRQ;XW@!H5q=SXhpFE~0tg9c4D;2XZMLg=#Pr~WBe zwL`s^S4%_>AvLDX0*SDtWTJtN8Ovh+JIi|3NPe9TOj<s^tGQ3L@jP}*?id8G{v{)u z50zOZhFI+n$?)f_Ddm)YSk4)&Q&D1MYS8IBCMROC{Ss5fP5duNn>RILdVap^M+8Y{ z(P<*KQv#3s{1qnuzVYm2ZSrcfm;$wnFZ5D%+va3*2JwnJ2DP-!5?1xD_=uW#bz?N| zc-QFda!5!>*#<`ux9G&yICHAk_52N<nYgD_Avs;E9Cgq=VbkcG=6N>bu(EsT2l2;! z9=DO+Pc8D#bz2ZA<pf@QZxkVC!PQlHB79}4jP`dG>@oIqXH_l~$-g`+Mci_E#8E;* zf?riW#_>|;PVFIZH%cxqmef877W;ao>jg%lVA+O8!@p2pP{xJC4Goq2upDEMv<;}N zmS^O&ZvNN-p`Mu6#S(q<rr2+$hPvw8_~rzw6O=rP!@P4Z)yM7Xh%yH<n22pC`{hkp zPT@*lXk5U0!ojM)HEq3md3wZjrHyFzlL{kLta39QC5MflMrXCqV<XCe+0VX&zXAx@ zKaYI#FDX%kwlvR#*Ilj9lGQG#{WN=Rr#eXxZaLyN{nRp@tHf~sP@Ktn>0_dSl)s<f z-Fu1nuxO5??^;&zJienP%7u?)16&Kmwah$P5)4$UxVg`ey?AGp_745B&Cuv5NG#%5 z^4{6Z^0}UWTRIF23ls7Q>JRWu)#i@)66ZfvZ3$sKT$>Ge>X6+s$Ypg|@dM^U%PmLp z%5gU`(fvEIX_0aOKC7Au>K2tIx5LWT%cd9CJa;kuVJ{NrR}<?ZHL+V7V$03YA3h9^ zvCuV_dh!a=lmK!6w0eG*o3_VMm;2AYXgN7KmC?Qdc&VgDpNy0g4#RtLT7bc_(n|0m zSaq+43wLNP1sUgFC?VfR*@sZSPGV1s{Jy1(OGG4i;;?<HkS`ncR*90oG5DpQni0A& zS)L9rOXyUXWU;tA=f57KZK)sbO)%G-#avuS_z+0xpR4Ti(q;}GWjpd+!Ry|YWE5kv zpX|MBGZ}u9y8oe(g@yIr-Mb13^6;T<0K#LJ*XL?o3nr=wGp+*2%bjyB5TV)e<3JyR zH+%nOx!RQMYo(bJEqRmmb=$jCjE2j0JaH~ZSr#5G&G@6+@l!_i6V(ogY=umxHcl~6 z3stx+QncJe=6|rf{9kwS<wRGUkTSk}`EoD_B|Od~yg-Xjy{bdn;iDsqUxZ%yN4LuR z6#PZBRj=N5;r8g?qvJMJuwGrIBp}&{)OOEIHJz?t4hvRF?Roh-G1r8gMlGaGjwxWB z2j53QKH#9XM&>n_(mgN}O7li1Xz%T&1c*7n^9TtEk@=;HPrbJE#5BK2s7pw21SzOE zyV0_eh&i&EWwo236Lh|_5q<DLQ*-9<@UWu7@k5$!N@^}`Ek8T_XY@<IfV>1P2FZ}J zGMwCKzm#s^GcJflnIICc4t~X2693l=J*vMj=6c^uaj9CTfF@?x%w)Z6HaaioLQ83+ z=Aj<?o>iga6s>q6Dk_?Op+uI6B6q_=4AgmGE4FZTh<fulo@cB;6XLe;GRQ2tf^%~& zxe1e$yTJ#gJDKRh81<ql2r`tv@1<a-5qT$~wzhT}etvGvt&67F8Ow2GfRmIYzRjeH zhB!5E?|%Yc;HawkML_&4Y}#(E-)-sZe7;)QE$6jMu*kj|b6*vhU)bcEw&wdv1n>8@ z--p59j?_F24bd}{78lQO781I5y}@Id_r~2qqh8rW+E03oUR9MQlVf#l4WKOegm|kf z%Jm@=jyCs`GbpH8X>*g31SBN728(fNqZB!LKkmxPzWur>BtNrFBbPpGyqq(V=z5K; zZ&T}hvN=#&9U7Vn`nhahu}p@U&YUR&AA9d_-yTig(JSYG(E@;Y++TU>f&>N~;XgNd zYcgyf(}+;qd$a*?!TZ<^L&m`tiSEZ@vLAOZNL{)K@%0T?Hz&(tAE+9`jg{0ghLsRn z_@(R^#M2jBpAqr=t`VfqY-J#>{aZWX2;g1kM=A$C82H`4EoiQ+*|ZlBD>z5Tvdu}a z??n`<R@$r<a2iUE1shW|HAg0<h(;S7ZrqVaciEn)W}NyIe8RZ5zrQf#q(W%JdSEf* z^3L33ppo*sb;aD{iPuR9(fLj`1LB>gP#YQEA7WTC=H}+grZN5xIx219)&Q$pLX)%G z87|S?c`UU@N$dRK4M_ual+IPP2sZ5UatKo)Q!>Pb>xGa|=Vs0rqEy(1^gPLT_#@}0 zaS25SgEFJI`HnMty`NxNN9{@yQ${t7p(+jc*9#jbcH?5hF$_vk(d*2Q59Luky_`@m z2`U`tcZ$n}-7k+XEFlNhu46PBZ%P8+LCk{*A3s(d;4Rk7Lzd#@bFhFBJheUX*{R=v z6&5b_5zo&*8Oo@y{IF<n3mFxXCnMwLexeSg1Q7Tv`ahSusWQvGyT5!AL9$PBdbx-h znA3TQx)Wl*`7~W~R;De$+TNOlmDQPun4Tx0yU8aAKz(@l_#I)%l|fYb<8``}$U-4Q zJ23bDbkmbhp5FTvzkdCi9UNo<6fVFh<R!){O)+z?meQ3rlsPy?F-drDZuv5^53#>N z$Y~+o)9w6*-{tVFNtLXtTfya}3zVMb5I!SeU6lx<=P!uX(`6trAkR$Wb@<^zyERcF zWAYFEIh&;)VK!SNes|UO$Z_3CO4Z{|slxFg<{9JXeMMiAqZR#VP*iOnh4CtNn6<Ww z+(S8B)F)eDX66`moouTR_FqxRl#ZyCQd}kO{EG#|a_F}b4mnj)MAqh+*PJk7AyDNs zH0T^i_`S8s*U;<u3`arb(MaO0RcE{Hd9u2{B}C{ni>Iwz=k>e9n^S5r?Zr`I!(N&n z*!4Vtr8ch6tGk1)1kfS#WHgAwgF^M67ODHwC$qskIww3kz35Qx$tNSS*IH+N1)9!T zjVx9Rjvo?0avZ!`V_tlbn)1aLJLzzJ#&Z1Y0341liI0LwvIe-J9ru%=zLkggpt?UM zWHms!k0NLG()MU!Luja}vC%8ja<b+K#%_9abd)I>({6&L<XCP#k(QY$S~y<%uImJ} z&R3X_W8+!q2n5mVV(LBI?LtR@`m;BVi&nD6_N;RshZe_~ePFj!ODab8!%@BI7hhmS zmK}t+Ia+)J?b?9K#eSz;H~#4!iY&I{S?7pl-Ia7!sDYlIWE`7RSi&Px8oQZRymaLg zKDV_ID?CB6S{8QZU~_}?bNmPzd$Mm|znTrKcd)-6%hM9BwiLEOoiB)v&Sn~D%bX}P z9edYPU~)F?Pd)r=gy8`Ok$@pNMPMx?meYNHr(V3L$acc-y;FQ5?O^jzNyk_Mwz2E+ z8J^^&f7?v;9eVYj?{E9On0exUKq$R*{-M~kn&=u55>sWr1w}2o)+{N|oC5qg2MAkZ z1+cf$xadag=96JgiCxBtBbg3`A%la`@zsFE{rb76E3$C^Eb_XtBD$HX0Tko&Kr#Wk zJwfJx@<^3b5t1jt!7fbELHsV-@8}Z%3+1pgJzC;WZ@0m@Jz1M+8p)7tY<x(AUD)qm z$7&eC1mO4XfI#2mlc{1m9#jVcHrsmZWfL8pr{qs=3droM457n_vBIm96EA<GBO~+Z z3e9ovBh%8dQk4QV`iacG)>i0xm>64M`@mwAE!<3{P)pVERr*uVu*R;3pw>;78izv8 zPfH-U472430iWY75`NTbn>P3HeljmFWI)`6*g)CL-W#d}-K7J-W_ApheRme8VGWr> zbg2$#c6Wvxkn&r-17fRew<W9ldt%)byS9gaH2LYfGmpsTUXTz&ZM8!&YLv*MwTNF8 zl^Y|EO)D<feaU(Dexz_mJQLH6uMXgvirDW#Bq!p7#zn%vP)d?=>oH*#oqD0w>KCYq zC*K%*^jcWHu%Y_=KV!p}mZts8VxobY{w7BJ9isQ{d9JPHj8v35b82&2!EiVk)JqKF zLp_@aEhSfV@#WLXocrnu>#mizu^6?gTvXzmKt&*v!1j85fZr9mhvctUmm+k_^8-@R z);5dgO*lcP(aT&xC1JZ15I;o*TD9Hj(f)bF?YLI7`*`F#%=KhT?uBT(>+{+*NbdFZ zUS=Zo@xlwzcj~}>;cYKOF(PAZ1{z@iD3(d&Uv${u*y$6F9-}06RTWeBF-d~1&GPX8 zM0W~+NF@B$VZPNRhk%)ABfZWnEM(+b-KoFcT$>Zvuo>D>mi@>yqV)ii;FZO66zK1Z zxnDEBDgV|jSs&{bp{$$`KqyD#blEIMVMG4Q%l$GVIoWA$E*Z#3SmnAfOT6k4lNsiw z;(^#iwd(=!p+}D%xt-3h0${ajz=G9zR9(CKB2Q`KSe`L6H@DjAYSQVTzRLBYH($M6 zU1?JB^K*&G$=vI+lZA8%fL<pr%PyDOtV)x*UnWog8rBL^n><-dN)?jP)U55D&3g{V z$?U|BP&;C9em8Y}autNVw{IQU8^4xi<KPe)_em$7)1Zc2quu7J%H8J?Npn}2OPR$K zGdp{@Z{I7*K+-4PojNIK_9!xOufofbh&b)L{gx44)nTY3B@Qe4=U;wW&ghw#(3I)G z7*1ge<T43@XXpLNecwFY;QQg9JQFtCf=Iro^4C_tFB$8#eqLJAl$wm}5BsJ{wp!LR zF5yY0mBSqtbC}|vZ*B8Aqy6HDUzqx7EQP-{Vmby0b@)qR;~9*-sIL^Qt#ZLK=Nv;1 z9oqXT5pP`(^~hm`5_O|vFuK!&LVd|gMya>`n)8$8w}C@V4A7k7kFPe(fe0w<)7`+V ztgPm=m!`C|czBUOR8{^T3<+D2+K8@T#Nm@uk%^Q~?u`9sX?a=rjR-dv_a5xXRLnqG z8G!ZS>B}}e#RH@M0XUi!MNr*nm7vY`lW7oR>!T1OAQ`;d-5M(n@<TC`S4hpVDtn+Q z4i1^Vk9KxW+f+xO;*E}u23iKxI$Wi`>~(Xsk_eA7smcog3rQ<0<07XPM+=$D^45m9 z`VGszV~p_A^4h(s+#!azg*f=secY432-E};6#@>Uxy?;%Vq&bPPoH8FJwFUT+M3kw zZ@ANFLyfpzJkidO#<D|_e*5;&c(%&PCh>-392j{nC=<`!^gtrj77obF5EnT<K0fZF z-GuH#<4VrM&GF<~PtT^}bcrucjt|~jtRLb_!(I*g)iS%9oNUji(r2mk*-wr3T%BX4 zq<Z=4PIjpzG^^Y8s@1GH(Ci{f$O9gdy06Y1aR;kS37~u1K#H{7EGHKfM9ky1H@Hl> z0nuj}q`lb}kFkh?0tR8G!=Z}eyL34+O4w>jpWTuzhuejMxw0E!%|oFhELgg_SS?y! zIp5{!=}iE+T@YC=BVw=u$yI*KVV-Jh?B%GJ3rNl`*XPT=c?Nkc*wLZZw1Ae+V8Syr z%-9dB-r2kMY&@MQ%g?Ha(vkz+rdVlH3MKA{SsE|7`~Z@_U=Kh~-S{o}cZMW_FSn)~ z)+l%kGXse^%rzLpH!+A=PW#CH`Oi-kpwC;B$}MIxVwWC;X>CnaD;z(&-t$2O;9sQm z5vL2UGHTd<EQ*Z#FkVDPt6cCj;uydQ7FXv-`ehmK6m}FS0~yY2-~hOB`X($Y-4?i2 ze{E7%9*IYaB{%AAXF40hbzvd!Z|#-MD&t$t7oLrUGLW;c7Pkz(mP*aF<Q-*n=O=>k z%xr8?>>K%s?q^$D4l6f&hEi_C`nn0FOhTR8q5;tE{iZQZy9CMx=35!DWeu+$@Hnhj zSho~=lc3{B7QRaO&_elg_gElDz)nWqmPNB>EhvzfhwgOyA}UIC0-u2GW8%eZHCAg- z_>`OVe%iyH7<SDn+q3I?56BC@h!2e9MO*g9oZ%9m75A+VRgZnTa>ixQ*aY3}3n5#n zJ5m9L9Kqh+t=pPY)KjxR%|*>Lh%AvavfowXVeB57)$H%+(@qqF{yrsT0cf+l$~Dx^ zmvYKY`a~RooKGg6_jtq7eRInFUtV6pXVwXyKUx-2B)aSeuWx?7b!I`?vd`L9K<m~u zJstO}Ywn<r|3_Kb)qZYa5DAYoC%>G8glX?)G+NzN)g<5j<lf!nEO>uzS3+j7P#3je zy2wXE8XlC0u(|Kwc^tMgUQ!ww=*LD!tEsD(S&WGP`k8IZ4I~2G3=F;F<53AIr%qfz zsRV>GPZRy5@07xU0Eo&cl`p9FUzp3qOb8^B#j4$IdC;lyK-TSKZoX+LO%D?jlc)NR zvQHo{FOQCz+IJfsdA%BFo<^q_kfJ>yB_*{{ftbntl9P)`>NQjEu5BCt#<Ea^gv8E? zk0<MAV`Jm+up%nHnVH!-O9Pa>hUm`dhOhJn7NrzDmb*Y$G0@LFXBPs&Xsm$m)Z;K! zB=FQQJVizDR=qTL>Npst^$;)lu7_GkT%3l6MqV>5WsP!wjjUaCZ0u0bQ_#z1P63Gn zDHrtHQ`&hsMD-orR_Qr7PBdUHZhHz@Va4Fl{(d05?Cj~$N0K$Aq645X24|Yeh$pTp z&DM%k{q;Q`Y>97YJKNhLkZ*59McZ0iA7Viq-M2Zl`~Ijtl-jD<nE?}THyZqSu1pZ$ zN?aVZNlqDPWfV2vzFmuRm_0bDr#+Da6+$26VhLbqx2jr?b#T|7?rudDmGYlITLtEj zI+fJaV!*>FD;wzO?B9Jb;5I?l0DahBSLfQ+>*3J==t4f1-J*)LafviB1;#?@c<z1( z)2;s`8L+Uh0BtVQGhC0sy=0&WJpx;RuZVY-?zc@LJr)xaqo$z|WG58x1>%8&gM%mN zV?;6Dw^|#IuRxpS?Jc6Er3JFvBsbFVW8ycqRB}*Z9~@$hM<yurf0RFSzCaw7V~vN0 zXZl-;B-;{uM_?p8EG#WOeV`V|SBS-*sTHg|et#=kXkc~Bq|C7f)(5{)6KDC|h^)R+ zVT=O#g6G#yEPu5=NXA@TT<_k!Q|t%IBo-Dy1v(_*u!xAkPeNym;Xs-TL}U0=5C1;P z_f(+tVr4DK%KF*~v^&_k$%NPf!kwL+#U&*+Ha6LL`T6ELIw|StEdow|S0doGe{hhk zrX?>gs1I~mAR|<%C@AP6{Y#Qvo0Q~%fmM|WVP|It_kutBkYx5($~4Di-3%^#n#kLc zQdKpnU=)X3c#r#jXJU$?N_+l4#CL)65G>)5$%DU(bL7wA#l^*yw~OBfkS9J86DWt0 zQ&WY48D>)Tb@Km{VJRtSXmso5{U5Czw%VREYCcoHQv6?`6xcc%^xCl7cZq}~kWThT z8wEZH_eMgy-C86hMS{O~M?#V{{=4@7zn>680T9jb@(+tEpdpw2H8thDqRy(?lc!!k z(AO7)DisT@KQfSt{NwCXz!DlvX_%Seh3Q?66aD?WAgs#m`d(lX)i@0WMLHj^ZEsA4 zkJN$g$qe=*6xH=2ZIrhGq=o&PHkavNzI%6Yf4}f7{`;y(yD{Pt66#fEUb}`T7Io&6 zrEc3L;6IQ=P6$8$Cxo$%jy&g|?>B5b(gptT6)a3Hcp{J(LYAg-yK}OfH|F_ubE2B@ z1?xd&31Dh(i5Db+nHxj-S6)`ONQk#xkvv6L2e^CF?P*CCH+JBtvsJZP8f8YL@D~dx zm@N9B;N--mpbD(=y=;GrL?kJ{OITuJ;c3a`4hk<6TUkx5Glz<u{Rj{txnl(iqsjr9 z5<rYs?T+}A2+Ie-=k{&w3VwL(faExnotvwmD&*DQ?+jGic}7$Yot_*PligiM>l4Z> zPjP!How|Oqn~t4bz;Gx`i~vkEWw_GK36inu0M^MCPtnJL1%T>bX9foL0<BWr*|~(X zGmkR$wV!*T2F}x`G?IWyjpEiHjGJK1ZphIAFdZP^fJXL-ASa&TD#+QO?zIF#@>a<z z5HEyA2UaELQ3eP-t%9^CqdyiYL1mcDWZORX(|k&iO&tn7{~D}28+-9d4`BKDL&;5| zDCi`|^*ioMPSI%~LdrlcbU?``2o(kjR)@{$kt*{C9U!U|7mEhq%M?;YYcX4y=7C=H zcxzO`1iI`W!>fO8nCL#g>L5|vp{l1i3;+@y+jS^>ALoy0t={Zn(WiSC2L|Fm4p<1@ z(}&aJYy}FXQX=teDhY0f>$?aAA)!>E3;UrADPzE6F=2X%-$;Ol0ik|oGXlx=g4o&F zIUlZVZ!HTi*VNPiCLhS4J6wgu>skX69ap;FU1z4JFJ}e6FsNfsm3x87PtHwY(EoiN z+khFlKLvw`x4Ek{4)j`80C9UE@@Nsg5Xk;o17p9($Nw>#b=;dMk&&Oz_u*2Nf~3>+ z!k$a060NDcx(c8Slj$kTfZ?nJ@Rb$`x86s`@v9d;m~`ih<%DAluu8j`j)7oE>BoVm zAd@zh=;$uGUAR7B%+1X$RIB7=x%>sNcLuFjSOi2&7MoG+Sy}#(3|+1*?qhkiF3U8G z?z`91J|~`tcV!EJ%&4(nUZ|_BW@8t=YNoC|H=A`i<}J5`J;5z=nMf>+gAakSn)?jq zuAIPaHQe9tk+Hh8v^`z<M>IZ65Bv}5xdH{mScSZ_H00$Y7{C7h8xXxKC8%g=)%Q-s zwJiD@T;bXhlHUTnM5)iz>bJG&>FFsfvdRE+*eEphbEmzlYjN4K?%Mh=E4Zqv>aq`1 zYLRr;si4%f)(%!S-V3RBbUNKl)^<PgTB<s;-zwe>f;l?%CAd;kQ|HN(C7(EcTBrdH z_*?#^6=io&`P3thS!aldi2m5>tpf|<OM&h)%8}{>sF~jKgQZIE4K+Vpq)}@O&mDV; z73z4_wu5Mkr4uumoi>~trmk^WY9B~RNugX$ZF5Y{E+4xA-k`WGVzerBy8EYRGeB*& zybN?Ew6S?oF|UC7FVVNDOvop&NSuq8m?lAabFxI;qttTD9>9X|(P{^oKSo-9W2C%f zX%h0k_Z*XG90ru$DVeZ3S5YcwXXlQ1(Wr#za2#CR-mW`$KGpQ62-{TlrF`reZhm85 z^_UeTaJK#3@A03W)64X=)(~M6HPVdaDA9t{tVP0QvYDCaYPB_aw6LJ_CxoQj2B`)Q zH{Qg_a)if{?Do^9a+!i9IKZ%K1U`i3=LbwX5peE*j{%x(vX>^)6;{G%c-B;vg{rEm zM#kobCKmM0SglK-vD67?VPiYp=o7BN$D{uU+yw}Ymew{o-p_pp>cD6CI4!r4QS0%I zX~?|ASgtdl!r+&CR#)rhsxsSF^xW7OUISW5#R1+B%=Apdepe9@ZzkYjl_`{9`<3_o z{Jy&+`+Xzjv<JvQEc+2|Mt??y%knxV<a%?chJE=R8Cmk5QmfY($n70XI+w&Gt~ENF z^9u}mTtdZw5duAypSR^IV6mL$t!yQQ4|v^(=-KOi-@qCGQL1v-Oyz{0Bq@1K_|k2! z5w^_#qasaJLV?Y?#+*+*!{e7H${_u#mwG_m28!2uy9;SIH-7i8*F;Q4Yd}-_zv&da ze?Vesa9<yD08V=V#FaZU)iO>}8>9JO=BJz%^Tfx;dEp#U2?+^dVS47~^gJ?awHJcX zeeX?l1kqM&|M8@?fW15+o5ulAE9j}R3JOX^$^Zxt7x4l_Fh~o9up%o^c1vE4)*<#* zXL!nQ1@Io+Ul_h1A@x1>w$?6pOJ#DonEQ1PXvb;XQ-WkzlZmjg=^PT0lK$0VVqvkh z$?fZEY~Q|ixLoF^Wnl>kNfp;(W@Ox%ZxYSJ1$uA$?P05<ocagoL=Bl5^=|R<^ndKs z=eL~-SyP>8yPXBd<hh*M7bvu0G_s?vx*wr?iOaLzzmM{8jB|6Wyf82jgYjf%ED{f7 zx69gn(riOHH8$toYDp5mh%c(<0}GJl-janKCA7MWPZp7d8v5arKtDF9PrGIh0_ue5 z#UZ>0t+LXof{pCA{R}hTm``8-;9$(F4@6i&+S0*TIMXvTR5Z|SEOs_F5PCf$g@kr$ z5x?3(@Rc<*F2k}du|cu-4wRHpq2KUXZw3=0>o3=1J$*3Hq~bs?#eZft3&5Sk3qNeg z)y@SeIVn92&H1)Fr2Ar_OV~e>PHR7N=Ag9!WTQ-#%&90VETEO8K9hBGyB^J#sX~W3 zvSE+j79L3!?;n8(ZhWKPs##*3ml2N9wj!-T%q9!@?4aD2cWUd^1m(=;_z$5)5t2v- z%@wRl2Uzq_u|C?ojOYU&fcPRVNZoW6j+H|xWLP;k)+f^?bmT)o<_Bfyg9pwrq88tT z3mtdo<^`<!ns4yfA}gCrY2p}1D5xm3s%**k;r%-Rw^iS^dqgf3%dHP2;efmXe)K{l z06v?q?t`%f_$Crw#|r(8V=$%0Ked|Z)_CijxB167?C8{f%X<3~`DO$oh?G<07bVb4 zY)@92?}x#8&48Mn*JdS8F{lPm3T{^;F4>fBr@yAhD=GlUegG*cF2*F`?(PCk3uGEs z&dU(%dq7&9KU4ivDvmdxJX%~@dbZjDGV^jZ8fXI?(CR+)=^Cn74z=3t)SbKrvBcgU zBi+(AH15}M^HO0wE}G|u+xmJK-<Iq?(s$kUVaogh??)th78V7kSowK*7@M#IAln6M zXtE<&fDqYRz|I#j5kvj`-=S|cHC+ct>+>(2qnApChlf!PcN~uP&sKU|X`F;jO*e!d z9A6(Uo@{ShgbcxzsJwaH4j<D;<F;V;7g~}+D+f|VCa0#vq#bNOKI7Kg^+GfJuJwC0 z4QR>AT3WqGNJ+TOEiIt!1T@9u_~a`t?x>g;n*F@f(=Y8=RG_Cs*|tz3HmBj>U;t<v zC1u-9(a-?^0)HhSqdLC2IPsSd=j9`ejfwixcH%wwQ<j2F$zr>C<Uhy%w}v8y!kG&U zA0XeqkLu20zHY8#*8TUwbKwX3$f`hfpxuoynK=)TAIg+TJil^)t#54nk?w<d>Xj89 z8Ceq9ISgk7s<MAil7UjT;9afX-{z-*c8MIV2(UdoV4{EqWC#~v6&X^s*)As*4GZM% z>1k=QXSXVvTc#P@emBRl<1Ss|N<LtvvWhHv){})Epbder$qLjriXW})kLzvz8+mKU z6->XE2e|hw9(!Vbm$baRZ`YC%GMMPKqFzAdV>wkNX0j5T+7{1k7lWFR5YQtjm7P>j z!2fa118gIZi2YFe9ve%Dhi7T1(g%k2Y9m5IL)|aWRA{<gPs~Bbd>i0EhlZxe*`Nk= zr_(2pu=wp2WyE3jVg?0;{18wrIv;P7K6yfR5ti;w9}R+#LWpQk;|<qVclE$AQ<$B- z3=HM5nNF61>B>-wz|7%qwHyFU0UVvE*P<w9nfa9W-#0dV7e`8+L92?6vz7n=**{)9 z&>y0uAi>#a{vibLl<%%<@eZq6wf6DUO)?O~WRBFu2%=yfj+1<U_ZlE-_S-2qBwSDp zH8q0Y%8{GxT$UTaRc%yN@xk2M8BgZREdb;J20o2nDF~9678?5OtRVWTmz!sPj*gz_ zE;q0RV+RAj$XIDvd3klZBFsgDh%<e_@Z3+c*>OqUs!LkTzrX@g5=NE|8Xc;yBlKuA zmenLgE4V<D8!#8)z86<plVv_54WRCJf(ncE#7}YZBQ`^iw<ft?jdSiD60v=30P@e1 z?b+nqTtKUwbYv#}XIHXMl^zw@`-M^v6UWQ&Uwe#<XOg-%qfOaw>Wzd)L~yuYk4a94 z_<yFUbbVHBcc58!C_@7Zk%RLi!rOEYo;vI`&b_>glbs>g7eU^DzQpOV+3lYv6B3Pz zQ6Dm2D++FtiOVyRR#zvqWMrh_b=m!{G&bvc!OFs#z1t-NS=-o9VVf%YAO!qSVIJtc zJNTg&$MHZFY%*J%FU8kJG0Xo#G>8YfSz(6P3s^V|5*C-<sU0~md$0r)u<+5;a?eoH z%-)%mI>ZMZosj8B23lI4kGuUvPg416V<slK%8W+rkK=$w+f#4#@-(#1_^eAMFKr)C zVnF4(vc57<A*Rsb1iBwkw9iNVz>488g>NqKgYt=S#a(!#?`Cgrj`cX2z)$RUliyq~ zFpK;|mDP4<F#T~EgJVu-K3mQ@pO{ZX7iv|{79#$okd7R^zQD|lc<t!}#v~@1$6wLx z%Prevq^0TU>6sX1fu=%S8dj&f+WpR2c_$SUhFI@ne3|J2rV79Sz(~@ki_soJ=OcJx zLIT5CR<>^kfK%0fnvs>oovgSC7>5nNv9miOp5i#!oDj0s3DR-}f>}QEN$enRbkJlc z%CK>8K#A~MyErY+YuPu<CpNgi^x<WRNz2^pdtTJf?)?Qv);$2HetqrWT78o88O#aT z7^cN46LUe$F28itm;p|4^moHf%_#yOu2ct17Y;)lWN3KGUr7^8T!sg@0$8dIV+)gX zvM*;F2v>!?1Htr42KggW{!+V*Q8F?zAArj_t}*ddf#zkgEda-0<xT`(KYj!UhnI~s z?=4Q28plV6+nw$P>vthCPBw<};L$^rRB=xe1P_KvvX~4Gvu10dRh8A6)%LDn8myEY z)YO5bJk!lX3Q^GyRM81Jl1{UoY)E5ZQ&aw<0h9`gyT$|D92~uqlTd&I1EQ?BxYT8L z7fs+5;x863-zY3$0jmQu+V;oYVrqBlsBTJ|--ql2w&?ZbX+QY{KIdb(>tc%;?`#E1 z34ZYp=!`B$%Xp-&r~Ei>=VxFN=PDjGZ0BtYmFZ~SB79^`?nQ2-7PDMxj-q&B*4*;Q z7bQ(e$;~gm3RKVb0WY%}m=DyGY{AL~n^6Nm+~{x+k-yV(RRlC&A^2E;p>nXvt#{vH z00XD|>8{m3fiwzoedv#vL4wq)hneibfgdfwM+_6)cy|tD&fauJQG6pED~B}zB%4{6 za=Oyav%X;Nm#5E70d(4C2?k|DhEBF1-xCr(n3z<VceUcu#~k>E*@Jd%GDFj+qT<xX zu<zl2`A&dXwuQN<y&UG!f|l7A7Y?OjV+-?rfdhsQ#LeS~h_gRl1Rnk?)i_jtzK*7> z`4F@zb6Z>V7e-*nt&X?x5ojBe*gD6@jjqnw<*5f3gzKlKrU3RC$K&wYz5{Z#3jwOZ z**NBSJQ6Rjhgd3KMN8I)e|Q3YAZRvB$G%oc%74av3Y9ULFXb<W<KVMfztND?(>uKf zPJ4uo&h4=N#!Et_;O{W62X~quebHNTVnXnX0dOr9QBmuZ;NT}Sc(}M?Js>Q1qSZW9 zLXVE=tUP_cNYRt#oxSk9aT{Q*a`S6H7Hwg9#tML2%66ja+czGi1Lgul6buW+#KcI& zim&RI3MgQOe(UaDTYJV&&cn01klv-ZHeDII$t~O6wwk%j>1=OI%wzjqs*uLef&t3& zP=?3s7A!_W8b-%KFT!;>;bXLgKacNnbY|yrpVtw1->)qZS%1+>6tjOp>i=0tFe+;& z*>;RW8&&|6cu9?zkjomAWou>nqeP505`6rY90r<o<{5?VLom74$qzkkm#YF;Iq`b{ z2CwrH4q8zx2;`)teL>*k;k5}>p%ZeFQ*=}ckkvarr*}t<rQ(|TnLwYbXe;>*)}wy1 z4eJ9xU(wmw+uhA;efaEe&dRa@H2)b4eZ0hw6vxcmd^T>+dpH$a4a{JB0Yd(GCp_2B zUm_#{g_E=fqGGZFV4Ev2^<izj9~fUfn|M)>X{f7g2R0D=D8(U-^}RfxTMr9IjMbQe z7!J8SWyR7i3G$5%NzcgG_ZiK5r!pV`fHo1XFuhPJ<XBN40ooNbGt(gN0+!FOFb=wY zgWfoyI}y{ON*{;aZyfSH_$y=sY3f?7>Kk4Jofg%FQC|YzjS7|Dj4Pd^@eoX4rUUFq zo^@k=ok8t%3aEOsYd-FUjO1P$j4W&`C*I9_zvs9s4^;i+h;M#Ry)=5IrO(gL6=f9^ zSeg34-1O0&@$sH0X*+){C~LH3WcPDE`?0dIEg^RvEonXY&Ikic6Sa3rQ@KG8z&EU{ zZ8u@{g|X_cHmo*SSh`^FB+>0`3yc~5&521C**YF}2i~Mu=mg6%nGTGVtTm968NgH0 zYuD{}Pm};0uC7jV*3}t9nBLlu<W*9=N+`waU%%Yk%HP?QS<I}1P^wa>Sr4Gs&hT!h zy%eK2ITTXY)oDoq0aDV^6<`oQ|DghhIw7IfN7=#sNJMSOAgzkZSnL?1sR@6H^DAy{ zCaafiH}CJ}SpQC=<<∋kykQAux=i$QD!{Ip(+QK7)zAU+=05`fVV6j53<bY5&)% ziyCiFCwH~h0mi|B8-RsqlBri@nF7RU3W>i+4FF<9qsFs!A5_&Z?r0On?*O9#z4kI= zj|m+>X-Dbhx;dRkW98vESo$`R&#E5(@d|->g%%{!)^G3{rX7FAcLA=49jo&G`6y^; z{+KQI%)n^D^-y0O$Q=>!tAJXXe<8wZmD%`?74|eEh=}X+wyutjS1=qIQ0GI2H6z(J z<&5>`-b!O2=)&Dr3uas@pn$>=t35x=T;7?jRS6b`!~NP_*P4A5K75$}Z(n-BzOd`< zCUKanDa@bxh8p(9>9Y$+%r$Y?t&b;r^KBaRK)b*+FOKq633T`Se3%}n+6NM`4Ztr? zT!7jHotWQGdw-{p+#d)cy(mGOz6Ut;j7CslyB=&gfYC7UqcPZ;U>xEbKIaDjhk-r4 zp@2t@27Gn^X(xyl$8ptTx52?17?|M;rYH|vA7GG({7{oLGwajh!+sYQ7$}`7opP)L zLF`|A^7OH`w$6@=i<7hj3X%?QZ<PP@Bn!_}C3&AauQJSJGl8PN1HRZAcs755jDs#F zE!Ty2P(NE~TD~b%f4z^Diw|_OLzF@8rvsFra;<`HaknT*4$}{Ckum=O{7#>)nb{$z zVL*u&4&AQlg8-surqpQz(3^uj&EpkCS#KRj!Ib94O;rm<Q}e-O$k7G_h^bB>*yQ>V z5fOlP#x~wlnk+HpsZIGTaj}$&X*pXXER)Eq&rLru+Ul(x_;I-X2`=sm7@B-=1Ni?w z1c(({aKU3Vk9@YQkBG0cn>yBVJvTI5ZRZaQj-IIA5vYWZoFLTa;1f|JQOU`_a>BbJ z=`{z7KHN)Kd?O;fN90$W09v8h8aF#2qOwk`b{rr*s6U+^Rc2d(8YtuPNdV;wm@3MY zCW_}UN6p25@uCSd!$1KMt$of!PVO}b#tUizq^ebEn+zm`evCO%v7@EpV6u8-bQH|t z0?rc`jJ|63SBu#G{cQ-wFEKPh9j-@{N2X&1GxZ22wP|_=dOkid0gE#WwP`J^zocS6 zxS7NN@-!M*Wqkva&NnJOHfBo$MSNb&=2J}iT|GVMj~<~ue8^tTWI3G`41&poT%W#N z0XAvkV>~=V1A~>#%{M>r6mkIC321rLBA~kkt%1o?E;%U9Q)pj*gU6^saQS~-lLQbf zhb#}(*y$-^;{b7OTj6#aaH@n8xQ(4%t<A=*z8$=D50w@P-1qTdP(R2|-`t$fd|i)+ zr#d8LAuVkQ;L#FdV#)9an44SE*)RSkiQ7|xvxf{fryK;fg_?XYHA|p4|NFST3-3Mf zOiWU)$-bS4;X~zowe5+rrt$xN;sOcur^{$X|K!^^I=<0xsbOlF?d=^IL5}SBdlAQ@ zny~*$5u_Ua;oJHBn+2l^{rz!<8^Z5@U&><puV_O`8oU9<ZVwOf|9gVeO$#fa1cb1D z0TM-}Y@ky`z9;(=FdC_CeX>CB8mamJm^;gdD7UcPgKj`cB?Li0QKUhnq#L9eQd+tM zq`ON%x*LWV7`i(oRJuhvB!;e`>nykK_dVx7IDAxe=9zh(weEG_*Y&&jwbYm*a}4kL zi%d*RTpVwFi38Y;t9gG(Tig3RJ#;CWDWVw1W8)rqtx9WaYw1lN12M^`>p0ms^c`jB zQq^~H6^q;h{dUhV=5esCr<#-N)D6)wbB1<j1N%;#j@=x#iWim5STM1|0P_Yh-N1hr z#f2Koc6}yQ-hV{Q`7HLXSJYJOPPJg&jkjGIt(mz&RjIo|b~;yA(_hhSBd4$u%jR=M zJk?G0p`bnquZgdU_0REd0St+8<(OFI$V?uYK!KJ<4Gqn5=|D*dG_-}Bz~}9L(?aLn zCA`jsps}mCgTg>f9sdXlI0(oBKefH~rC%}YL%!E;Z3d`n`ufjq?*Xy<4_lwS;sFL{ zjDnUH5#eh6$R~n2VCWu|v@%tn4Hlof-D!ND0-VeOujw20?{Z0E@QP>P1EC&eU0}mw zFg<ZG{&Q6l5+*KUDyv6|;(ilC9wJoNKjZ>S(|HjHGq!@{<>eTLNoztPBDN8{KD?Gx zm9s5RZ4WAzx1L@4OVR43A$}!b_)%v*p?#R`*KtRHZKCX_<?|OvVc8F9fL#T{wR-Gh zfT;fZi-_1%l4VH3#3X-uT63E^HwA`JH#zFf%gHe`_z;(nA-spRp&I{AOGD8|oNQpW z9}J3{OTk^zZV6`p;c#Xme$7tGd1a-s(z$x^z>^)#XYFuq%n}zj@jG`!>F<~S6N7^Q zYSXdNQOY23LSkY{UfZBisp?G3$WP5Xly4`V{qrvC4P1PzB_m@u_aFyB*sH4vcMbJ2 z=Cl!?PZOY{+O+i~HbqxuE%)A&pun;Ez=(s5e2mh*g$Zuq<{t%a!+FZQ=qz{V>A!S! zfq^{(9~C_>%{PF0iaFnE;&C>c)U+^=m*1VgD@jE~Nh#?1qhq#zZXh02NcF{wmt!rd zMSTp9%bGuyU9II6mqSFI(N)DRMejOa`J<tf@oEP?Kta*HL#Teh?3ELQS6TiF$XM1r zz2Ut8pl}w23Oa-;t*(8Us6s@bMt@~X@C>~MC6ySo)TqpS(NMCY>dyj{O_>$-k)OU8 z8?SoR?FmFggoKB24Nrpe)b?ye$;!LFbr#(aw<ocwso!pAYu2AqZs%C8;||K>c6<;Q z?{?lrS9%be5e?DQG_f$4E%((*RU4o)iuU-ep&6KQQqGovfyd)R=|VI4!}B*UIWxSv zC<GjQW^3ezzVo@9z3~nWtIFBNp0u8I+ke&HF67-c?Ay`R(Lr_VleHM-j&z_WBYQlW zYB5on9Gl}mJ~pJ+OnUrg`pbN2lMT&Opv})_|6Sf%n_p@RfeH<tG*4+)GD1QQqI^Bg zSN;7&Hw1WTP6sv!N=g`$@7%c~ZX2JROji=)94oQTtldCH!Z_0|{SaBY)$}HC)?Fx* zP8kl?1b<0+(9qV@Ogi0})6u!^DHFR&F{ra%kdv1h7-Gr7*+RTusFlOgBC}j-n7I6o zVEX5upkYrXyU^(9=<xWwCU?)we7TG!_>A)o>{wPxF2_#I3HSE<_oQf12ZtN7B>^_J zzP;Mp&hk(F{U6+jU%r&EE@dv!yr6w^Pe#Jzhp*_jf>&8K3ZdkA6fI;AWGSg438`U; z@$qx}O$6lRWQkF4FSEmI^1^FWFP%!jc+Xf*<cog+RgC*f^1*89xY$R8k47a$DFb42 z_}zJOd3Jn}2age3Bi4Hpxe#%yQ$%|%lf<cp#uRv>K+6qGJyN)imXiw!HDB)pd)4jc z=jJEG#c6724)>R6U8uAUB5lV8g|vco{RRaAqp3ubJ;!-~P=e)RAL8QSVIX0W5133= z`k5xWmB9^hUAZg2XvB$m-pUc|a8}EVq3bV0!p-XRF*6GZ7YZXN1m#$SZAx@f6TUn< zH7_yuoz~QJI71?SOo4qQ1f!(M<=pL^n31L?I=M6M$+2dgg*9DOl@w&3Z~y`o(|1;h zGfT|94%MB@@zuI}dSRC+ONrTXAi4DL@na3JiXrpC3debL0$+g=U@kjGaQ@c|u-277 zsB%!Ht2E+yhlm=P93$iQh#DU|-R4b;O`Z9k<2%0ZS7^Qw9GQ{ZG5qEFbGR7g<7*k| z%krWyw(F*z^}<oXvKR`<Z@O)^Ve*7XNJ?sIBM<LLtK?MXN=7F=H8)L*?KJDz7a8}& zqN?ZD1coQu)`eZ^SM%4w4p7k3B;z;uG)qz3bV|`X(3its;yI%x>#>^5d*gd?;r2XO z%I2!SxVUKYs)GuOa0E8&ovkTQca|O&Mbp;jNPDp(@n%bSQITF7nPe9j5%Rnq89=Du z_MDyOS~NGWP|EZL54QPOivh6{3JQ@g8y1R>2%<>KNQIr~5DODrq*#rlKzb_zmS?J1 zIb5cIw{)p(poE#392ODsD^QK;C?G`TK)1=(5?Wt(Pf`zaLqhe_V`ZBC#>bL+-shv5 z>^6;ii>wzv`e2}kt6p7elK(PUV+uGMRP>`VgS6Y?<I|Zm#<I97t;aZfj<Zk2x!@JU zTCs4IF}G2ARTb)AmNWgAZ6tvmXen<WqFdE5&73Nd`zXK){ODdjRUG$@igaI{$Yn)) z*V9vYa6jbugVnnUh-&a|;h^Y@n#Q$*8J<R)D{l#rDjBUI{wRYT0wD!=x#xX|BlD%F zexx(tNiHenW7VUG^f%8xbdHa+H$pgp1=;_Z|Ap$jpTET(;50SM9^30X=V8B1qM@S; zbb?H?2GAjQ5fbKuaUq6;4LrKG5LP`qeMt0nj*3pMBpqK&T2gv?mHiMZ{DE{S{oF2k z5ccN6OqH!@Pco6<Cwi*wWMnI+^%Na;r3CAvxq{xg+=_2O1H6Tt12AQ6kxcT57m~PS zKkFf)g?SK)y4yw8C57qoYIVhy-CFNOWX~@n!RT1n&=G6W8+hedxJY%#FA25kj_R3h z-j6D0u}=3J`7y(?q`m4&(|;ZfXG?ON%;38}2a2>KynDOjz6rw>F`e02S&E0%5}AZ^ z1y7CaL(>Z3)ygCJIc(_SAL2%LdCyjK)+;<PCH4fdQ&}CGgYwr>j<o`wgtig${F5*L z#1LX0l=q$PVk4D)rt0?7zAmj#eofv3Q$&`R%2HxVGW3Ba<<i^;8I0V=X)S6P1DPzH zL14u1d{jxFkyj-4g<4stUp+>WucIFsGMpysK^liyj(7uwDeiv*J@(D#$z8B}M=Bec z>Zs&F?x&OwZ*q0QtWkxtm1)95!W43^`Z{Jm^GB9PLUNl7Pf?c^@~f*I8b@TBSsne1 z@N>J;n|1lgT4~+<=XFO#d6cpqKXJ>%?AT+EW0$h3gN?D>YBYtM$llOgKNsP0+g(KU ze1}H3011o?Qi3z_o{U-^Y&54oB{2QebV89Ud7gQ`9E+bG?dP`_PQn@G<^CQ8MLH0u z$FeLCb#52w4U%<03yO>6kF*dl9n#CUV44u;Cv7}{BsX3<K{I7LpHqR22MoG3k;mdW zrJ9<2=K^wie$IQ~$wITT+DRBl{o(8gfR0c~&JC>6Gs|TP`0l8v5{u`?p(8wv>%?qv zW#zrKiQ0KyVT!A}Qm_5(((?-mX2%K|_=_=h*%3a4)XiZ&UZ*|SarT7S=ETpE*093D z`<_-fEvG*7PbXZ$v2hSEM>RH;q1g&cL8efP8?dd8k59-Fej-V#*X<>H?!sX%cDlCg z^se~;a(t(F`@=|`75p(hLkqBzFO(Z&s6swi6&KzS5{$vZ(`j{!g{jN6@XgKX6v~S) zoS6@7$c>!dY{Y&W+newhr1vxeb(&;EI#h%}0dIPSk+I)w!G-XV>u$#?Mq~$zcH=Zm zy-r%qrQ_$rI1imxyL6L$z3sqEq@!addmA(p$z?Mm$MFR8-hklsSVw1kVxprX$Inlc z&IL+ncM*3`W6R2zp*w<}d%WJsr|jmq6GSODTXkCwir!Z~w#T?s4Fxf^j_bOb^$Kh@ zZptdz{Pw$4a=HAs^VgDbx+6=nkmpuUc%H~-w!A%VnY+aGKJ=|JvgCkQk-vFiA!=%4 zD&t;X6sD}^B=6$#LR_AZ(R!}J`7i(yBZp7Bv%EpH(@)qxk}4;s;Ekm9+w4cSu9U9- zLXh0xSHqDFygz3ks^{Qnpc2;Va?X=ZkiuVfFn1oNPTR6fT()mL`W?)+oS3G`%&A#) z8ssf^AqR_M)iw&Ld=Z!!xa2Z&GB5%NuWjdpDeZhHo9eL>J*0q`M7H9dl`0Rj*$`~5 zsi44Y$oKiG@hV}%$@Y9;EoWdP;Xm3fyOQh4Ef^7lNu1d+JNvWB)r)66_DB<Rdq}bD zLn9B5<>aG8TFq&11j*_m^5H%E(XCOfEN5_z+#IXEzZ2Eqdj9Ysgv;i9tO2<gO(2SX zQeqUJ(d25m$5?U}v(YRkiE3=+pdxL;tiSZIStzq<91#GC6h8<~EefNugnkz+dqN(6 z^)8D%=tsZ_b*M^5S1{Q%gYv`{hO#ChA-=SJV_m(iv99iyobr#o9f-WTy;&~9X|bg) z+JbX=dA4DB@fq$H$kq=&;u`n_b;OTIU-yZxL#=pEfrz$^#?{@G^P_#<5w{gaFTqAI zL4zQhijCx4%_(3C>S9safZ;oj;E~Miun6rb>>A8_;R-|6pmapc%3yK3FWE_3kOmI2 zEhP1WY1H598kg}rwTjT*o^47(0;zmd-D4QYOi!5gbNXG$OnrK*<UCQG!F#A?RD;VP zO`GcU)RZ(1m%QWqix%}+=l8EXJy!_QpX_YR?5E&p6)H*VQBk{x+7d1g+rR||nv?FN zxN&8(2ej&qI%^c}ieII&Uc(u41tk^IvQq>JP7lqlsxifg=;7?vpa#7|3@>ftwt_yW zQBe>U_I2f-IWP>8S>p{|?5rERxR;t9!-!d40ADDQoa~+9_ztz1X_n7>zt_DE?d=R= z;@wcrxws9ecurl!gQUw8VA%8?sj+uoa!ah9N(IFkCHpGl-azt~1(A4x*Kn=Z2b>R2 z2;fEgo4u<=ljb+Ot?3LKHDX1*UC;f10y#fF$3J}~$3gqUd~Gm26sb$=^pIx+%+0Gm zj5tiRk<ragH9JnGC6DF$)sjM^BI;U5*|Q?QjWG!=;jVHNO<Lt|#u~MaY?6!FL2eLc zdN%z9*?hy*6}3>DN`ncW3r1S9&4oJH@P&VETVV+61PR?u7+BC}0~8cwWMGK!S~Aan zz#_kz8fdcSIZ)8}mahLxCruE}#?U%!dM^7p%{d?V>!mLVB_)orUnv|Kv%W9(q)4fB z0bxVk-n2rQ42$&n^955g471H0spw#Dp;s+>nwqN^bPLhbwbswHoBcS+nO<?RM2=SQ zI{SlD9Du!^dvzEJ+nVz5HrV&_E33N0pdWql!y|SwJ$6AIS)it{VI?Ahzq1r0r)egb zt)Bhv)ic(R<<HV?ulP?1nX;n%sLZqVo~5&=rIRO?o+S$0lcs)gmvNdwjJzHj2Q-#M zpWVs)Iou-P`7+LFMC$X8X5%9hqLNX2E}Nb7JR-f<<WH+RhjqnZiaq^-Q2R#MPrwsk zV`Nl1%z+hz?as|=6KW8M-?$ntuL;gL?ZVi&t*Cis10<xXX~tx3o2;?W6t<Id*0~Y; zxp>yoxfLP;^X+r-@*RZYd3mP82OOGEVj5UndULWfL8v=Lprq1MhWOgxPpNt%Llp71 zy>=@<&8Ni+Um3j@%=V=xe~6qa(X0yLB9EWOotS~py~{O}+a%5bD>p(pizAKCD=s4g zk?Ck}Z*{RORtFx?Q4Lm3<_r-al*{5H=^{tbwM07FueOoVH&VZN*((s}RhLNmKCm_Q zkT$@JQ+<&m^K(H*m%rIPBAW<{d;Cq#`pZ9_R4O<)+e2Qp7%aC^G>>){6~~;#<tsFu z0kZB<z}xY!OH-8lg)w{<XYa|$$uD9yDIauM6nigFsLGWUK)>_C_Se+Y5@7|h@u&@C zaC~G)<{QTdxRi>TQE|aKR3*a8FP|h18Y}fnQtDc9DD~$XUT(|{B1uQ0c|?fP{ZP#_ zv2MZFFD>E|y1Gj6a@TU-8_d(#3%b~>*?*fY{AZ$2sY`C2_H7L5ov%N?oBezb!33vP zsbl7R6_FIi<JJN-?$aHv4bS@<sx^4fPynG2uY|tU$c~RMSOBxNQk4bjx(jALzL+PX zJJ#>tf}W9;z4Tb_A+a;pWW>(QtAjJ)xVVFmILIig_<gJamiVe_>y2M@W89D{{4a#@ zO8vqL(U?{kCwp?4Smbm|(U4*Thg@Z=w2*Hxv1#%)Y51(u&4!XV#=TEZWmR|5LkCGj z+`8}2d@E@!QXwPAxEjsR`WZ%dchz4h$6&;^7o$C97L}GRtC5E&2#@?}z#Z+Xd^y@g zBj}qZ3mOKg8s~)4&6v5ZFgJe5P(!+iTFzCyYSWY5k@y=A!kdK|Svk31<a~1({BIec zi-KjK<i1iQ6)^HqN#t?6SoHOLHxd3s;O*P8H$0=RMklWrMQZDsl$;K~hLes;`E%}F z2kXbMjR|62T}z*IC58!BsA5qT?oL3n$0V?$)7z$b>C@efXLxBo-9CoKzECLphj}m} z)BB1dT?t5gd`5qZA`3|89mFxqY)nH9qt~H(^&ZXG4Wi`Sq28nGhTd=aS2m%F<U;L7 zxbxI<NwlQdYO{|^HpUi)p65^;Jzl|;kzLr>*$io|74RS&v<T`*w?Lldkofu0pMbq5 z{!!~O?VAEb2vUxp=cRo8NTOY6EQ0GJg~xE>u?YumIq3IrSx*6~;0tR)Hl<A(hYLu0 zBn8!}e}3pAg{Ecvn?5O`tM5O@uA?kioUiT@QA*1ScbLuz%`2`Fk{o<qgM>TZrxPKu zUHMtq8<}rGDP{-AI=#3qA1tUgpFh_^%{jHEr>K)55&IkhuZPO%8(L92F9MLXnB=2C znEpeBJNaE*F9h1gAG}YSjd+jO#i7dM_u`Vtj*xwvfHL>s!0xrPZ*A=(1q^1LhMPA$ z2@|9Zrf`cr6?xnfC1qOBSHVC2X;+?j<G`44f=U?l5B%As8&Sr-YaelhHxE+yyH)(! z(~NiLFy)46UKw@Ao@OTCs%d<+{V-i;LtA}eBJfb`;z8$gim}ogqow7vun5aSpByU8 zo1<Qg9_Wg?Q<vw<h@bFjuCx={2Ff%^Q3!?fOz^3GVs2b|8m^Q;T*i3hqi3gL17%`6 zbPQGsiGav8B;vuJH*7y(EP+~d5}~O#e_2D|t&7A%RuGuJ_;v-pZ+?WxJ(Mzz3)0io zbxM^Dx`YI~ujp%10e;+m!#1lY=|1mmXQ-q^?1c5G@}{#@#(VOp<Vd;r+^(us64~cj zeOsll+FAiM|42l9A$|kn<AtwIQpq$h(!yES2yMuP+B&feQS9gCNR>l{^}cP8HP6a4 z6A>pC&y}n0zTPlx5Ksb=s6Xt$oiBGU#y_GIgXWi<mUDO5FBqf~qp#Klm|WI~xO<cA z?Cp&ch0ck8)u2(@2#9}){9J85M}@rUoSkh3y`eM#=L%mfPa2xwi&A+zmI-y$tE&7t z1#23{X=?|<7cXB<dL%}P)U4K)Xla8}>!xjgj4_}N{ZxMa)={NVs)ieWBCIm9rG|`3 zm}Po8X34>(Tun#68AmU>UKOIYE#;&7<?1jW78b0K@0k(l`EvEfUvhiul?wAhDY^3= ze;hcKTL0q2vS;Ih$A>zRnNBGdzg)>EgtsV<xcaLKIYBlpq;Je?V+dM}diw>*FKB!$ z4l9q2p7N$rWzIHBJJm{$&ucWoW%BV7^A%krG0X`RApYb6C<#re4C!~xok8mp>Jwrb z)?DQzp12*C2NnoE{p>6CUG6kB|Ag4Iv|d8Bk$1n;5)^8sL_}i7`Ff{&HYjs^+8<n` zOH>l;m%kZu+f`s}MkOpdtE#G~ly$Gwcd2n!DO}Uk7CpTn41oB#cJ2*=cE***^5!-m zr4FE={A(Waffu@>(Q%zhaY5S`howzouY2m?h?$Ruk?2Tz<kqgY`FH;L$2)CXrUxf- zKxRRNX4DK3m6E!@_o93AL7;MTSl2H^5>Bc!-%*rH906PuJ#aR$MTm$-BP=TgcOrm^ zslTveajIT_AlL?WCWR1nSRSAH^7`(=+8Y0PR#j!ywe>=Y8Fvh2wIAhU0<Ogr9@~CU zanm(qDePOn-`6<^Bu!6$qE#2OfFWn=kyu_{uurNR=c&lSxVJ&30tMaq<Rp^IxkCk+ zp$B!@1RR8;lao2km4=s`BF@=7yq^fWc-fQHKTfcP1|yh;*YF!30I87b>$ce<*nO?0 z7*9G5j*>$x%h8Bzez%|9eUs*$`T2Qc;T9P{<q^&|yXy2%2})eAJQ0v$K;xkwT)Z-- zzf4}~hN=cikfI7B%-k+iHFmsmm*6+^YX!dUo|xsdqX6zN-u-FaSw9rApQ$d;rAnR1 zIs)0=#JPiIe?D29o0v5q;v3;-8e_4&0+wmy4E8nI!J_^f8)Mm-1Y9GYP*;U>>HQiw zvMSa~uXhfzkw^2H+;weMoCj}xop^XpsyUGO`e<dWzX<g7RLQgPQ5GA1Pie+EmMl-d zFZr){wfs_5ltE)gz0r<y8|IQ(>Ts&CJpk=TWL{OpH*eJ7hzLZJm#r=91m+57*W%=M zj%vv-WN1h*4!)O5V)NJL+$Pj7Kyz?WI!N#>+^8mG(<TTVebQ$B1n*v{`jFb@>yr+Q zxmGI%NlEu9*b$zl-d^&WVW@}E6IFQ$9LlaD9K!uTNxs4UUZ^JZoJW%bp~OZhdaKO- zkPh?BgG{-Nf@#sZBryK`=*GYbi8L%|l_X1^Ut77K<C@vj$oGj*xW^D6V}=W*F+Jm& zB~ynx0C@iu|8q88Udi37slx<On}&jdq}Ta_hXNYU>fEef%WT}8qowpXqrE!#m9+o; zo5LbT@NC=d(+~aMpT+AP7QfWpmRFr|$k>gl%<*IkQGiVoM-;SFIdCM%^7h1Aa=D(i zgOw&A;05R~Q!p!kcD!1dlGKmZ;dOqiFtpg2%;5R-1<#a#3t@)h7qoX~mNYNatp2Iq z{&3DTiO|cO<r^W<{TizCs*aSGdxykfu+3rB?JdHS0x*UGs;M){o*n%AZ3=YKdP?RU zDJ{&;xgR4)A5lo%N=OPs<X=Cd=GYle#&Woz;PCiJJ*fmVvgm2RV6~aDZ&;Yu;EGeW zb*c^ZGF_&Oa!q8v8Tk3TCng;+3=i}=b99J`t$Gm>EShljdZ6e#(!Y7Tzt>90mTR%+ zfqKX4%!@$9l1qIOaX#QhSaT#&y;mT?<?$Wm<-GO7=*Yt9myIIHT}g}`7;?!nIiW1O zFU6xHWn+Lkj&dk$lLD`k3h`|}H#g3nnE8_3l4@c@$yQ=n$ImOEtbiMj@><g3Y(+XQ z?)#K#z}j0o?w>OUGR-6T^&8IXqw!>MXWI1+j>A3z#nyOK;=_+*<QU*)iyvF5r^ayl z#E73%XV*RF{uJ|~p$~YkbFZ7FFO{hK2V`ezt%bRGLif<oGr64$v$7VyS19idCV#OA z9(AUVH=x~pp9lOuaSD=yheciqr7hIF+|y$lv|tQ3c%uI>S{54nG(8xk!EB_-hA=7A zZ#KP|Y;Gtb6nICGE`)gcnvEmcEl$rrMpax~(%Qm;=N2t5!@zSHUKh(nfbnkG^5G+8 zimuDg46nru`N_!iE9?Y$r%qL6c<HZyh}>!(sC5#)Pj7QL20H{ErFg)s`|x-ln1s^e zkviQ$8E+hoQR)#)Ps{Ka`5a&WNtNm0Z6gEedFuMpE8Wcw36V8?0y37Se=aTtSzKti zHERp&2cxLVuHQ<-M|rIBLRmTnx;EmSW4~6`J?un3tVm;c+}m<8j1D0n<NlBpQo(9f zknWCaBy|;|P(vty0=%3=0%qa@m7y8noOILf)pC{@8ab+?q)E@wt#PH1n2_-D#mk;S zg|^ZoW<F;5h<N6t`_JP{jeDk43Af`#HB_A!2CtO-J5u3&{%;>%A+k21vnf>{jqYPa zmxLDxq(#ws-<Y{(riY!q30FS3%zFV<4KpF1t}$3Xu%)*UQ?u`#F0}9;r)wlV(})uh z68IW5s``sj$6Y4Bx}$q4BqX>V3}=m?8UDB7cHNiwL6Q;&sO7VS6eu#b9@?&zd+j5m zrV4t2f#R3A6vnD?(Nec5J=~Jw-@YFBh1ZCnsvSGoVPV<3H@P{`yX@04a&yO<<oHM- zM0D@V$k@-f(t%p@1Tsx#JXBHBlJgqEW5e(9)?46}v4fQ^;~f-Th2Nf3i?Csxw{U~^ zAB<KQZ*JVyX1lSIWlF>)#LKi>e62Bcg?1VOux9upB6|9JUvqNCz*iK7+F@x;AH|&( zXMWKP9V!Ohd9F`I3FTm7;ul}LJXkH_N{adC6NV#{1wnMJ#_0CFQ-^OI3qZO+?u?-X zZgzZ6U*AH-mXFbkC8nDL?pJS+zWozl?Cg(z6fcNSIz;u8tPEX$r!{_8XG=EcC%P-c zuZ}3w&lZYhZw)gU!MOUcT9VyFwzndpD(qDuh}zQBavAw5BwTOfu;$ES*ge{fmXley zRsoU6Fu5VO^Rq^;)|Qe|R>#a#{Ts%Jy=zD$?ldZH-{*4qt&bPm;xAh}Ql`cpuAWo3 z11~ZjIX@kTV4#C~*`+EHAE}=%;4ZwG4J5jtp*M2L?|Q*k^fRaY-gNU8mlce(a$MA- zE}I9|+A5+RR)lW6qav*`0={j;+<|ooXuC1QO9hl^H%Vm<f;N0W-;t^+$KYs9qtX0C z9By^_Cty<`pMMwgOPviHE9?_~UAtXbpnM@Z`bCTT#p{DS6XzX%!iRK?Q3enQYr09@ z&TMKzLN*MhHDSCv@BVi4HRI#Q$*{psxU^)JMbckqVpMk0n>Cb2+6a;MXl*xHi7<>d zUHw&rB$n6Q*CXPx@0{zPpZRIKv|yIpaWcc-L3(MOORD;?88t<v&+Gz1f3*?+4iyFd z+ltWq1EHn)LEA%|Ysx}5J}Fh8uy}sINmLKn*L@bJFM0u~@um7apyfR0zW+(@K4>0( z-#eKjZ#t!UNv+JG6kP<P>*yVYW|ZJuGq+g(a=K8qcE17tOj%k)uO2j1h(YH(axyk4 zNmftRX8sUn_wB^$McE7EQ{?EOJKhtbTKoxn1lEP&>h&_|16tC|nVf7g8xv~cGBrrA z^2j|6M}Pbl-)nKX%;jZ*7tcQrlFu|Yz8P7U*F)OO-CtDqgIS2ab9u3PPsJ*q^2V5@ z{$@5(cJ{7#IOp+==r;<1&<C5(2<a$zs4&I)W#tGTM`?Xm9?WQCbg(yEUNlF3dIrE) zD%o=l>rYj=rMu0ZD|4_2Jd38M0RwI5%5DS8m*BSS(5V`-x-+*?PsDYw%1|8n&L{X6 zWIp24Pj7_Tk1;IA5lu)|CqetmaQ=Ygt;45o%%(IL3(VxnE@HI=u)zaM<wt~sLG$kA zp6ji^eh;^cB;kCUlu6~!S(3tLdm{Z9wJn8YRS&sd+Jdo)TQ91x0R$Wb#$8+r=?(3v zF2i4?q@|}C4OzsKkqHboHA{&1l$lXt6?aNvj25V-$y|43k2j~u95h0B<11C{LQCnV zlh2!S2Ab?oQ8h^yX6P*QRiXu0rWct9<vBKF)hDVPN|=*xr6Nb=x7IeP&RY@hvOa$m ze3~Th48SmUWxDG2JJZ!4CPdiR7OycP!IbH|k5a~4r`};EqNVye7<;=f_@k1C)|m8Y zvgFmCI`+<82hwci-ddeY!~-ydc*0YV>DO0yBEP#j^+|*>G|6V+zES8g<qA3uLKKyX z`X=sj%v6#=c1z*5Qc*x$hG4oKGp;EIRMf*xIaL^QXX1{@^$%x#JHNT&*$#cZ#hH6s zR<pgwky^VO<BlqZf`X<GOjs@@BkC>QlU9WYNGQe2aLR6YSZmV}gY37iuLlf>@F$#~ zyffK2U#Gls>Y5JM+Q5*ynD6Nlq*GQ+6O3;4h$P{OqQmnfTjQ_*R8UbTbj&+1BgiXE zI)79nu`e<Px}Gx}SYQ)i(DFArCZuKB`GV>i(w+LK$zxS>Uc-mx9+<w_%nVq3ZrxU5 z?9Q0X&uT4VQ$<HK*$KV>$k8}N`_L-y?5t~x%SJgsc<EszO+m&hla;6%vRX*VVtIWD zP9A-=zAJz7CO)Vtqj=AD?FB0{=9(0(Vo#Dwcuy1uzWw;hL&KGbVMJ)tql*t=9+JQr z%4icK!n<t}cF3WlY!eKw`;wiW3Ab`Tq0HBOFjpWU%|V*2Kknnz;=&uYzBxC>CBgUh zaW{{OFw`+81;51;zRJTrt=H_KV`p;l&qnwdI$@32Jx`$;mb;#w(m!0Z(7_D-3T~=R zPAN7tk*D0Wy`84zysM-l$;CrUTL-$ps+KgLAM0OyFpjB}RkFj}S+5=%mV2{|2yvL! zsIW*k%Qv<BT_$FA#R?Px{mp3j$rHE-CesZ%a*K}ppH#-_1%!Ac7{P0OSlz@yiX=Q* zGvC<onB@s2_2hMaUn8aQ_x?raA;#HUNPTULskI>?No`CiQZheN#M(Voj1G58D4n1l z6GV-RIkGm}-5oO0LRO4oTq~x%X_{2`x)l~~6PUxnIi~Z-B~PM6<K$FpX>`uR^TX|= zXM$%prDkLd<}q;5-i3AsoJeJJ{D|_sHmd-FJU3qMJ+`;5&Q9_Xfep#ao%3TD!ZeLw zP`p1B;YR}ub@Px4tF1@KO+qFqV~F%DWFwQ?EfQIUW1Q|#1zOWP5-{RMnD-^(gQ3CI zf|SBp^hWp1^gk#pyubBhHJ{+3rh__-r;E?7UrN7u21I^;dI5A7TFYU%H8s?#%D)GE zZHS{#u8H=U8uN+#cm-;!2)M7;T;7>atMc^BRIjdjYEGsSO(dn~%`(x2giMXKu6uTU zHs}P~wGECY8freB$fMI+93k?I-JL5+tt>TgQlIsXYW<<!Ds+)Bn`<lDHsdV1%a53Z zna=6aK$OZTbO`Wcb0{R0t0nbTT~mZ6?)Pe#b)x?hsK^l%jHXhyZ$6!?99&DJGrQS9 zne6Y`!fhkR?{xS9thY?Dc#kBAAf~3l0{l&MZxYxBIQ6%`kD0P4lIzy;hN#C%kdkix z)|6d@M8zoTl!`X}2t8GSzWFd(9~~T-4o%FmAQI<O>4v7mfs#hr%4nVPb-4{VpgU;i z=H^2vB)Q;vkf%RO<z(O*Kx?0f5lwY*x+9#Oy1;Y`hKpW`uWYsAOVej&da>`wHSHEG z`46QvAt)a(YUdB<_>xUr&^N41fhtf|PWH<BQW^JqQDHUz21$0JV(#cnrK|F^z1i*O zaG5NQ&kykqM2SqKt!T-M{4M7hjA2SpY59=}p#z3UY<&D!moc)J^D?~7A0+i{2fn>7 z+tg@OOWamlh(8g1$&zQWxZ8`@Gu>1`O|W;a!&3vZk_jeDzYCmcU=(>pqNw&&YgAR= z-}!XsgTA%b+7<OuuP2g)l$>zl9(z$;?AV|v>Z2m*BZdgh!-9wY8?a9+Mi2(g`T4Or zncznPa^?%Ah?=;{oY4)>i#S^(Z`Yhp*5`i4`O;XwW4GEL$g*q|hW8iLFKOSf({M%B zg5<OU<{AsG1R&Y$hU|RmxYuyk9?0NExCBEv5g0p~q(1ot1!r~QeFzzgHUWOkI;87v ze;g@Da3Ljo*tzbNn5SpGL99+-n3IS+)OQT3^)`8=T778NDR2tiR=xOMaF~!0Eqq*; zvMqRieoji7Vi)X|y?XTHBhubYPFI8-`jH!Ou^=lQn^3(&<*{V50K=+vbOy(d59lIV zH6V>*nH*NKz!RaLl^3c=#Kq5Fm0R64+M)erI9!*CmxhLcAy}RcnNUQ`H}WW_Wu$lX zFtDo?6xMNhG0F}c`_8b^d~0iKO)_B%)uB;%`AQ@5a(NZ`5F0TWhCkE0e&sx8f@x0& zBO5NG$rM3-MBe=MYtTe%Xi-*#vmFnOk%<Wi9D7^q4o|~(9tS>oo=!5vR^N$MoEVH( zd=NNyh`v1j2PKR7JMQW8tP3W}@|=_3Q&Gshi2=ffB=U*Il48cB4uqtQKm?<Tyr`(Z z*QokOm3%O+R^foV?i9pS1jki+yrh_sIY_39gLnuQ`}@DORau9nv@MI?Dms=*OFr?w z55d-~!sHCJ%zqiG<`6Ic->Mfh?d7b>FH_VJ6aQ&2e2ddMr@x}L$Q~S!^C)ocd$yA` zs@Ytf(UUjF7yU|4^Adw<#b-dfIC8IKNS;*TgWSffppW>dC@$UN{1Hpf+3PIFl)lK& zeF+PU#0G*pq@ho2&fUDLD}1VqaQ@AyX)|+kll7I^1H48LRrX4X>tch$<wFf{+6IVS zpz6~A^0CN}@LzZDYG@__rKF#)1>ks~>}!+H)JeUS4gKWd(Yc2^2Mo7hxE|<xH9My* ziA~;)f&#pzes_zPmq(XlVn!<uUq>H~!V<4gd`L&L%Ir3wd-kN87Mdw3z|)AWD+tcv zgn?$3mJJP!*c3k{8$6A&I2VB?*oApy;@Q{8!zRzkTWdvW3ky&c=c%)g{P;RO(cK{0 zq=p$W4@8(bFj#O<DGCZp81bu5ylAR{;@#ri)ZNs1*eIK!3Hd>_3HcEb2^W3bFBw!s zf$KPZC6ib8O#!Y4XvV5`vyc}QR}=x^uJMGqk9b*pqF&q(iwZUh%cqRe+Sbw&88`E1 zf7HG%I1?a%lDSd9Szi|w%77A|^)jLYiF9~i@JA2qvsn~|QdC{c$iPeaOAEDt6vxq# z;JK%xWA0zaSJtQne>*)h-9YbP(Uxs3iV!O>Tz?I6(@Y^*{u_*^qP~Ov4lS^o+Z7EP zMI|-bw-lH8%U|U(TsnP%6d-F@!nSAc@Vx&kTlSHLm!K5o=7Q{lc9{U&pUW{yf2G=4 zZWh$-04g)o-(!lhuhi4q1M^S(D_t%iS;cs_y2{8#Q-;fIeGjEqJlIN2p5pH-DYAkD zQ6Sm^Bxe$o<^XBwzQaST|6Y$CV&|i(rkkPnt_x)`PdxY)sp22!g^x7&oA}H~I7@q9 zIPOyO9rljDmJA|jKMK}B8Hz5>IhwffC+MFyA!JO;)=p1PuaB}X-Tt3*0xp^iWWo^E zyFTi7aQ~vc6v<O$rF#1`Y^CV_-#?bN^i@^)j4O#!7Po9*^xvn9ANS~ed;+u-YhRj< zS(qN@&$mY}Y_4-prtjmxYaew?T#)Dc`$zGiG*&rixr3_|`Kb8k>U}~L4SWK>lA@Fm zJ{kY>Ygtrr#P8#CD$v^*&@edu{8_=hJhXSKW<{v`7GH$_dkqnDRJFP9J%a!p?ey{O zM}7V1R}d|&nXxL{ur;Re&*3D0{w)ehv!<>e3IoWwIa-q=X3_l=5+NlcQ)DC$)=f#8 zZ-ePgpUHR!fvf62(YjA!re;G-<Ndr`Q#NB@3DK@L8;R}=E~<#RzBu*v-o3Qm1q45M z-zato>7P$`v0Hf#g#mCZ4!c5t3-U_Ao%b@M#ocN9XHWN3_m>f2EiJ92Dgf|$$@7w) zesgp+3?`oz8WO%V79Re2vQ%rP|64GsP|g34!Bn%Ls6RR}@FXKCfY44eKfGjsCewq* zZ1^b-PWZY&MRD==_odFZ>kF+9+KktQWo2!i>k(jBSEUY-lIrn8!zy^?Hcq>jFQ-wn zJ=?(Wbg{H4^)jwdnWVkyKS8|@y`m|-`3N+Suf=VB&Ka1F*&Gmx;-d^eOfK+oIEw%# zKbX#|E+@wX02XD`-j$@hAK%}`0Yr~%vWRUY)e*qc$Y*k<uX)>l#a6Qf7^phyDUrH@ z0$Md4kl&O~oyP6{Kd$V87ewS;*C#tO4uS?N-%l63GgZ?89;s=sB{3zX)_m;i)?{fF zX;?@^gU9)6A__@~a1z!ugUa4@L6`TpfmrK>AYk+_?F2}f(N(I^)Ik4u?(%Y>XAo{O z)CHuGZK+&^QHhz~;%InYUUURv4W(Q4tuZzG{0}fJD@cXC-C)_bp@SkYoPvMz>fBzH zv>?o+)!|^dp4D)7uI<~ck#vpm(OR!*J-`GZS``1YVW9M8L3Qq-;Q_Fe;RPA+#k7DV zDv+)ad_s;Lj=egV92@I?ycy4sSoVb5?hW9oPNY<s0s?S1s3FzaJM9UW|GO0x)8Odu zETB!LtvbJcEmQKjM8(8(NHV}e08%yr7dJ-dC^Rh0(7-SzHinvb5rnvIZ+l!I%>m{o z|3x^NJFpmIprKjKH16a?3dHa#0K-}IbIBwZl(M;|mB=$)O-;Aut`J}l3F7yNi%BuC zw$@!3uv+=_5Zt(v<8i<y8Xr&2>*jE@HlVMsFCHkqi!+OXP4dR8!$pnN4YQ>S@T4gC zJ(8lM^$jh@D#iQg#_~B1kVc`|C5^V7UqQAP8XCIsx{-U7M7T*acTUU-?%ylR;w*5$ zVvG!;xCQBTi0J2WbHGsUuOKTWpRYwdWHD8_T7U2h1BbNQ;M@+gJV{9|>6Pt#^N{oI z&x3jQy&m!f3&4K?&MEmpaMwA)Y1>l-Z^Lx()!9B1HvwsRk1{NQQG+VA{u=o!64-Ny zZkm~D-A_v$w<fDFJ-EGZ05&T?Fo!A!QERi%>aBJ=R#U{{o)K^s;Jn}o(z<v%;>Zjx zPj@eGf+;WX5dZ7_4uv~E12jU)S0A6wfd-W!)R00_Fn{~yi#NID<w8jyNp-R+#1wp9 z>1n_$OMZ+*zAJhB#%ys&@H#Fg#tuC1H+RwB@@wkqwmSBy(SN2CY=trqF>BXH!PGr3 zPj*K0ER{3!g?)vInYG{8oxPg-P+YR^eV(^+<$kig1iGzb`G8lkH=Hf1VzdHQGTbL` z_Jz?B&9ocrs1?eBaLEBLVrzNRt#9uK@D=*sxl89f(`qRBJW`@qiLd1~7uW4!5z9>7 zo>201_!ifXQXNqO!3#tur_~HdyZ&EOZT2XtnubQQV*We0a3{LM)<{mUp91VEkdCb6 z>;A(|I>>ahYt;uI$BpIgZ;?5G#Vir=#CG+NXbMKj_woICrLjs9pD~Cj^zNilN@KTM zc`GRii}x<eqHf|cdu7s3a-#_Pc4b|z35ki5AQlH;y$WY@r3l>2_wwhlT6Nk@W1ke< zY)@5zC5wS*-gB=7T<T#668Z4C#AhPB54S%)b17tUEBCBE1doO5OcAB`MWN|XhKIo| z>a|v#Ilo2M_iAJ{2r_6rUrYTTx14;zrs}<JX1zx3Sj&46nXi_{3&&z<6ilQ!^gF~F z+q|wXa)KjsDW<6?@+=qIeL+Gsu#!pfwenIc9<6ykJ06y#^!!<AEJOcczFI2DWn(lC z^opbh5}5TsNY<BJso2Zo*s*w<^!f(1nOZB5(#*#GTKjN4Qw7Atx$iHc7Ll~MJ-2nT zOGvXeHg??YIQMB6`VVu5qL>K0TbM1x7fZpEATqpS)6Uj0`Lvr@w$Ln@oYxWBkx(+E zZS_ikIRmc&yx)Hs6#9Dy8&}sQ0|C?gJExD5y87O94FeH~vWmfvJ?n}Ze$aTjzyGn^ zIZEd}Fhl0%uNbXsH#vo40#Zb5`oh2*@bm>>0^4@6!TkJu^Zo#r9_Y;cA)o-i6|uBx z(*pr3XUF|^-y-!g5Dm~|liA8y%Q@sZAJ3rvQXxeQ4QyLr-3Ls8a?Mv>S7&{R%sX>c zr@tZvM<yrN3F`KK>Z+-&t?tZKYp^v8Nq<sUM8k_jw=*>xE`P=Xg5x!2-YGwOaVF(M z?R*pTdJOQ3!M0FXZul)?C5~_kW@KXg+SZ2}sT5~yZqDsEJ?>~ZIahDnStm0IOa?XJ z!3AsqtC`w(_h`^^`uE32GY{?>a3<n`9)#Y&-9XpVb9=eV+X#sm1MYnwO~=5%Ak)&~ z<^3I=(L)1qs(SyWuK5WWanss@x9L!Fr-d*u6@y*^9TShk{xE_y$GoEQZ0EmTfHc@F z!KU<c3^&Lw4+cB(D+()`eH;KHSROY&c=U2J53#KI<?8jA@|cqAGXVjwzO{kkJCPIu zM)4PieP!D_PdKgSNr)ah_6<dHJ*g@$Ct-IP*0LE~St9-W_J!sYH_$l<zAEe=p4{vt z>g5<hH-;_^I&#Q)owVEZ?-Ef6xSWCSN%<KA8^bmv=~d@)cNmLq^IJ6OrbkSgU2&44 zqJ{u%RDH#y#d(CXwbB#YBh~Zr>&v?sxCID}prD}UsHC_RCYzwHTru5OuiRe4N~~JI z6O2zl@GkEN3mZG*g&Uhmzu7Yih>SE?0DwOTlqb&`U&y5jgj)mrT&u@MOlIc7@zx~p zEKHW@IIkh6a8fzH?~LC2dku<YVBZAsm%mPbFH~Tu8F?;Qs@rt(hQ4=UL5~FAa{h|O zU(54!&JPeN-wL$Y8V&(S5J)-IcLVu({up@ry=-p%Kl5Y~(Sa&|wq>STqpD<iX-4w% z6VPo}A5ktY&S2Me`*_>>(NRzeZ&&cK*4%V&3~>tkK`u%dK2HNyv-e)>IN=tI1u`^P z>by5!{8jT4<QeNTup@<o@HpYEs_n8o02XBXuYfBc0O=gOZD&v0K%yd8+805^p@7;; z?tKeZ5Noj1ftw<nkCgR5Xb*7ti8271t8uXjEw#>ice0F<ACh!41fP)ojZ}3DA%G5O z)rtj3f(1D+<m=ZI#dBrttuUVJIS{V2i|u?RKDIScw2nj`dIR>DEMKv^msjidRh`XT zjMsfKOH<+z`@47VBq>e(oc1{E0URCY{K^F;!T%}?MRoAuj*kAwzr6ChQ@8=f{_}FF zwU(3eB(yEQ?sqAsqI#5HE+CImU+F_Dq7MLSV7~Pi%je7aB6WrQ4_6lc!Fc4F%|@&Q zI$GsbVMQ{l3XiF*(VR}Ur{<cRW%wNeo(RYWe(}dRLO088s6SQ)1;eDCCpCv)%b54= zbsaSE&Bw)dkc*p30T5CY9Cj;NsCV(m`M%TF8gz$N7=8sQv05h=k&Vm32Rdmw|5emN zIuL-^uH%D#pk-srIphZAiQvWlbKQuLh=)*0rFZY5lAe-MhaMI`wrTB&3%6r;c0Tp< zyQ8Ua!af9_EuN}sa@dd#1Rz4UUte(|b12ykT#mrp_4?{)s1V?dQcu?g<2^#8Vri81 z!9us-ecQIUxCo|1fS!DD1RU&zrdkRo)Fwm8V-<$qfaSAcuSGH}JS05(xA&TOFf;Rw zZ3ifVmY07uo;j5mQG0rLTwPs(oX>+P(@X%1ItZy2>g}U-%dxvfz59P(6l*yxHng3Z ziOH%T!Rmc;tlk-f!;qZ(O-R-t&Ou&ME0`EF3^O(+2C$ctD#J5h-GKa3R@=5V5K<%P z>=inL*inokBqXU{oI(2x^r~F2x*p2Bx(0*Q1rRP4qmV4<eFLicQk|w+>m8oP<dhT> zqfa9I1O%}-TU;kwlZpJ~uRjB4A_(C+udgr^-3#<OUrm(tIBo|gU+JuZ4@O4SPCJ^? zNP3Om>sBNQs{ufBNk%@&PW5l`PY!eigeYBWW_}*K@$Wa+m;dpAd>WxprgAqVLRk1d z5e2{7c$;1u*nf2!U7{slDJ?HAKVs4B?*ktl2z+_aTW5W#p`pbJQ7-PVX*~~`@Y5F! z1~rJXJ}BtG{Ta&FJv_V!zLRutOZtLndSAZZ!OS4sq<^|*OZKx|?i#G$a-%ZO@LTxU zBj=)(4OQ^8oo>(lTG@VaOZ^%9R;MW;qhpc@l*ydrEnR2#32-U7sgSM3T`X-k7n7rT z6ciN5P1XulUsTjQYHA=A_h!8MwVvr69s2X{l7wOrPfAJKvZgdzm0Ss`;p1<V-h>hc zmDSbAg9BT8o2-(OiQgZx)y!xYA_=kEI`?9s8!%K%2Qv6wM|0!i2=%E3+RTqk@}+4F z!XhJqJGusFd(_M3?Ul2^odPVaZV6z@58|+uQ3hmDDUFS4Yip7CF*r4ZD%#pl9zGNf zyg1%cPU>FdFf=#m`unzY+2e7`-zq5`b_BNV%`}ze=8D-h+HfsQ6lG%Tw}N{DERazi zDhusCr_Kc1Gj&6KGXNy5`hT7JHc@}X2EuaFcpNu3Cjdm84-^`GAklny7+@Jg^YW+( zxa1n$Pnn5;eSFRPAaI`%n7sA8FT+E=eFe{l0g9sde;{NbB7hf9SYGqIIS1vhtLsq$ zQ*-Jw6cxPxsL6_GWtu*!@7~qBj;(E{0YpN|`NhR+lmgWM?AKYCx;5bR6!)9F`0r1_ zoje_NdAV>pI?TH$@ruVqYOTxCeZ9TXeY8m8YnHEneoG}XC5fkPGV6AHs!zcjHK8%Z z&D`}d=p~?$k8jsV(y12SqgH>@_V2;Dz0db{`6d@vnW`}dTgE?z5oaK=F3<1q4x?j@ z()b>V?lT9)s!1(~tt|j~|5@E#dg2p8yyIDzM@GlTN0l@msU*fde$NV6YR!Wf9MtM@ zI)A~9>R1yFh?Cb}-}P>bd`DR%I&`BL2N(ymKyi`<%zxjiu6~?;d>o+cn-#V2>0rjI z2~UiVzem}}bj88j1k1{mpUB^8ZofAt6i?N=S2ttCEB?`Y^R_e{TGTA6>FKgbVL3q| zLc&LXUvv^()lXVlFCy9-{90ilD-&qTxTp><U1^5<h;OJuY+3(a#Q-5u%M<l7r8N>R zo@q^N6y5t}6H9=C9)$e+-n3MnDP6Y)^kSFm*8Og}luxdIT+6Bb^Gy3pCT;5VODF&S zbW>VOIq&);J|X|U$ug8jYf<oEgXrJO^XXNO03Xdmex3A01pa4~r(wAIJW*3y+4c3g z*ppBG`S+LJf2NmI5jTKK_n<xsRKIXD3qae)&k;T2kjkd3r)P%DRsDHIfOJ@;kBPa< z2!-W@_dUGRoXgI^fp}n27Hoh$`{xk2&lqnNkavX=Mnbk(Sd{bpS-VUSt`>nP|A`x| z>2_Yd663$GD5L|ra*zRAHPA2vWd$4O`0s{UcIV0Q<IYmhSo`zd`SSAM?{whtC}Cz6 z1!S9BuTfm2NFF0b^!s$l;I0yX_D`23m8!<v9*gkOh(~klD@3nQ6s-ukw#+7%r-abr z6XO4TDJW6dDHD0qC6!7+d4<^-$+3D7SQB>(UN}O6_4o%Sw>z-Ry#6pC6z}(zvyZD5 zMaq|y;i!Hd_>7}lR4RqSf`x<Qs7&BL$<#t+Z2W2bPtS*5MGbBjUcLz(i)w64DXb9o z5{BpsKhrcoEw}@Y>t#hbu%ZE788d=EU#Q}JqGI-mxdJW~)gKchgMBRhEQ|&y`^&>$ z-fm1-+P4UE_x{(KQISPskaOhuz^0mngA@-Jzzhow3zZ4Aee4eCNZ=Am@(Ocu@@{xc z)eU}|x_|#DhD<i1B?#&0guiB-cP^~2uddI{tuAhs&3!TnxCoDM9HA1omIX!TAW6); z#p$k&>Ycw&wGj1#_fk319GesW{zt$6ulw3hfXn>P&;R$7^%QU#%-RHtAK1r0>`!%d z^-nBtM3eCs^()-(CO%3_Py4gOH$(qbDTZ1B4BlLuo&`YLH^%1~x46}YlX2{h!4fkv zDBP#O((876bJtf`7^Dow$H$vnS^^6A&zTv^i|WE~QrGfE$K#9RqxC`MrS!T5lYc*? z{mtLpaz%!Y=TywP_3wJH0XWG}BJ$?yNCD7hr}})B;R{~3hCMNDFK8YS5zP^S#QHtT zwp&i4?@#~S$bZ=T7Ks%V50_T`{C>wo=nf7JYFzK7fFSjoCbv8g2dnG~_E<irt2Zol z=3~=EA&J0E^Uft8GgI%~I~QHu`E70+8rX{=5PIo8q0{Wa!NAreddUeu3fmk`*I!HG z;>OjOJU@IW*3hA2Vrn5HNsEdSlZ>jXgPG<8b%=5|a0?5c0ys0c*&3ZT%&d&12!QL4 zlrr;Yl{+!eL8%dCpHj}Zde+IMKEj#ZRNn|O-*^nR%vYSOyR#MK|E_D)zajpLO(w0! zKQJi!iEbDf8If&d<=`L=q8o=ZK`4MSti^U4p%2ip`5l940)1l)fae*A<qZ<=v-TCi zd8|Vmq|4vL_JUgd5CkWIVaHDfCcZ;kJ0)q*z8&~!xdSM}<G+$5!byLY^C+pQIV~<$ z0I?4}$Y$W-`H6)GajUVqoChPj->6lfSXF!`D0usIU`FL0Zp%WRvb!45q|F%sXM&Sw z6CCwX3vHm@4^Fi|+h*K7b-)HKPzKeS)l9W+>xYeE4VweH-$8ii>w5IAnABnbM+D%n zvaG7<dM;{UGA16G!RMGl4D#OorKm^jZ_rSJJekm55WV)BzALTCig`y#Nr|LgIYded zI2oC_3=r+7^Ilb=mp?j0pFP8)ZK<rRO=I^W<X_od5h4IBCe1VUnu=QM?U8bL*+iqb zy^PEefV};?nkl0Ge&qKJ0JH9DWhHK|=~U6R^XmXehN{TU_FcgyT=*~zoJwm<9`z2O zR}8qB@)>;kSM|1bELX6ztG26)89*a0*zd$!aNZvFnyDo(y1h=!5J!!TuQ}e&_6JQD zW2<XfSoztRi>}!eG;g-<=vU+dV4(+Nehmtm&}C(Q_DmnZs6pYGnUwU_8)$iIt;gKf zK0lswcQ6diuJJr70rWuV*EdJ&LtA4_T>k1JA_lMLn%;0XHuA{u$0a3+oyw8AzdbhH zo-bi5N_Z(pIuibSN%gT10(Z~8K|x_5ljhmwhB6GmB(X3t!3A)9`1xRI1Q;Qb+1Ar| zJT>j4&CC$Zu6pyWJ8eb>B87RX<~J)VM!hiwfJO>%oHH1B6gPX>xw-L0YMCG#c+vMd z##hsDNP<Ql*!|D3)&ZKD;nHa_8_)Q^UI4)Tj><W?sV;y5ZZzsyC%}ya&{f-l2l{Ot z$oCR(+BQ4k1DG(wuMz7u`&(sgB5ud@Kxzkbx5wi4<g)L^(@0Ub%KA3~$wlLLP3NKy zwnz{f3TULVt1^i!Qt0oF+OAT91;CSieQ|Pfc!FE+eu^0R6}JtV^nLU5yCC^EL0d(2 zef{RjKDn)}4N#i77D@rG2w;sHH=K(=+aEwm_u6ik1aGgHKATL`9>=z7#yKUEc;6g+ zqm<?tfxnGcW8~s;{!JBb0bN(|A<*fbsA{o0o*v1O1W6$R=SVi7GG4H5jc?XAQTuUr zKNi48R}+s+0nd9v*1}?Xhu2rC2O1>!LoI<x>mo^=@1wi4MM|5uqMh_>D=rXqX=*Cj z<i=?y`A^tqk0*rqDH$2Y@3Z!{w-=YUjr4D1X$gok7rnW@13M?sNrZ$*)m`0HQXU0o zL^EF|O?Hmg>@k|}K|v>^_|ZT_Nm{cJ+_>>IfdF0hrON7p(8D|eN~P%w(8qp$R2ct< zySECfv+1@(iNP0wB!NKi1b3I<B)Ge~ySqcM0KpdS?(S|O!QI^@xVv>N{{8pvbDlmI zebL?fIqQmK>058rtWk50ImWFE&>aa4y|7X37Y+;&t;2eom7YJ6@2=|dI_F~$ldjI% zc4k;;=-AkVy<LT)UD;M<nXUKN^bxvp;Z^GFm3I-WtQiL-8sdN%J)7F<M8v{EL&ce# zGv8!SvcC9&+-`q7rS-l~u@rRF)>_VqnEbx)&ifl4?rOXp56Bc%nlm$VE{s;ONuSRa zJAQQM`6DjO?>1H#vS<N5hWjKD5fSsG0`P@kJm%x2f=*$T0Dzd6`A;l%23{1sw1y+K zxQDHXfJelvJ6vj=8vpIv+Yx}6%wTxo<?W-ZyM{*dP+1=)39Ow8Sz5hD3xT{V(65Zn zV7xg634VagEq`~9$T4u+oRLyF>_m~d4I%+~!rjIFz4%Hwv~#%mifSN{r`}*^yqMu! z7(+#l>D^L5aj_zZ2lw)&l=k1@_P&^hKbfiE-jA5i*S!X3U(106_v1TOEY)it|E-M; zaj>{ZN#lA?TjN*^FT@lWfB>Xw?coR5{UAvp1Oy};#<H;ac(SSK=0hpqvhf(X8V7hr zK>YAvtgvxB2{m7>d6VQ0gBlmoq?wumq(yuKqw0Nq7iq}JbB1@;^G|=BP1jweh2Nrd zh0)VJ&~)^6ch5WiH4>@#`#cty$bg6+_KSej19P}GHaJ*TUNb>&OV4t)LiHmG6&>03 zWOAc|Ld==~^3-GrWk<%m`Q_q^>}-7?+*ByzQtf)!-ZfJG3suFl;c&E`7YNZ@M_u{8 zoc+|41c3;!kWbG|WWs=4_tDu;u?MN~H4^j7*=qAYV~u4$^0I!f8;B<g!79njdDoCP zx5FXgp6`#zithF(X8!)o=fdAh;Rh33v9Ojq2n)A_k|NaT>&YX<$0yg?!U<H-pD@KZ zCiJXCBO`m5(-cmKp;f%p{l@e`eSjAas#4km`9mNW)G%Vc)N<MiQy6smrl_b`u}BHz zmO<S@0fa}@vSm-X7N?bpw^{&alqxry-2AL{^5>@;*q~6ncG?HxOzt;l7|eP?u_ehw z=<*XEL?tA+?1UKUV<R)yGW<M+nAI}M_CQADT6ZNQDQTWpFtmxz9Wzo|PL6RugoVs2 z^922r%k_4DrpUm?)&|Ii3d{rvzF@&i%>jMZfNBH8bAi29b_uon>!|gp@fQJMV+Px! z`R+cj7B?;pVY)g)NzETWqM8_(%;wm?(H;Z>J*dflx3}#t_xHeN)rTDX<BRRZ=y9as z&yWyooIOHx(bt&zVn9ZQ@xsW+LK_VQ#qo3!r`PBL=L@|@|39BiSZxp9XDb4%#p%`v z&e>oJE?pqkIV^;Y#?&sxQJM+A(oB%4<h%`)a^Zhtwz6h!to<%E+;0Cy++~4RFbo-s zBQ2v)Os2ESbm;5sKOF%`>$-UqKbXm~6(=VZ_UCHsZ0tx$Nq_zb0tO_#e!+@HhbLx) zn4Za#Gqs{!rYv_B^G#y~{FO+ch0_{KpdppR8ljoAnemUI58S!EwM|xFG$UFg5aF5g zhJ1nVo>x=<JC^lwr?Z&-63^e*$oS%LtTKbd-*-OuOhi*N?3-|-OHGuaw2AX}@3|{7 zu5_LV9tso+d8zmm0+GZfA=n#9m3(09bo9+mtiRbcBZe5H5-cpvvo|eA<^ys`VyQI? z7VjXyZ6kXB^%s-%e>DC<LW)T$$;_-Y?r6YauK~465gR8;_-Kd2MF#7WC@yz6LZW;0 z4te`OAcx<TNK<Vx0nrdO@$iV|9l1lNVZf+5HyHBRTJ;N-d$_BVTyowrSI|W-)fnm- zNKew?d0a$2FfuZlo}0PYUszaN41JGAEOPvic>OnUEGiAC$O<V($LGH;_Iy!$6SXGw z(`IFKVm^sPJf2jA&EtfU%49nH0U+iy(%;?P!@2p6sVNJjh^)>xN#kt8HRl$ib*8bQ z08pV$f^s(^?sQm8YEsgwV_)hJue0p<ni2Wg(Z1OBhYv>O$!t~ov-K~U`5Yu9<TY=4 zCw8mc9BXgxT`tS2%hRW_Yq|BLxG6+~kB;(gk$<gQQ1{M~e#`jvL))h?N@KzE>c{g> zDyiB&w=7nUnc{{zI+QAO)_v7$B!f{=Kadbr5JjY!82JLD8~puiJ}&tg5xmhi7pHoV z;wMwJuvpAz$Url{zd1WH-r>h;Z{u0&D?Cnna5s8=dBB;4L9Q}UadN_3oQRA?-v)#^ zKtl>=xp1VwfIA;X)q0*e6!uGD4L^s*dUzMi_sXiu$3XBD8|tzC^CO+e1sN*Kusgt} z8X74rEgte)v2J<N1gyH3afykl%BmzJM1b)R8kFein9*=Eq|$)bgWWA~9b5p;?H4$Y zyHll_K%!g_0RZL>!Jo6uws425iu)`ThKF1$83Es;#>YuSQ8W{zdVvh_=pWus(y-7f zDy_bd*jqnqbvDOvGfRHeHZb9T{2=XN4Gs>Dd>=_BU<^d;*6l>xl9I9U{QO7=i5de5 zT0v*3%T!6pU{tX1pfGDU=zaq*3&?jm{d2z5XTML#O2RqYqMk_5B?0d)0XA_bB^317 zPtN8olalO9oX+g<t#)Q=&r$5#O+Uv!lP2UN8=A3=1?2}UXATAhRL!fw1vujeX#!a3 z%|8YBTpDNIHAlt*$`J57W=OL{v&0x#`Aw2dzbi!B$dCxS!uJv8swyjsCD0}?e{X0| z;U6;Jq1vK<b#PFEPOUC1CZ_!~^oh`;T$7Nu_tz$^bH+9i@%vI@u^B0%A#ur}wE5hE zQ&9<PR|h+jukPqude*81M`dN31BrcuJblOen|)ofx#1u~xzQI}oTdg033V=0nr%1x z3nsCIW%V8qtfh!5eMwNrGH1%P*FeQZX#gzWoF7vG-$?TVK!*?*1ZRUTBI3YBvOhqO z-%7Y=^=lWIpLOBkFTU+WBU&ey+;+(T+xI|HENDwC$BaMD$iM{u3<Xt5C5XC8upa3# z@}#;m1Pd0{e{aUKuB9-zF|p9cos%IxV)KKW@vIl<&RzwYj~GKOv2quCTIOnEVrp7e zq*QvVR}nygmCC%U#9hq4JVN>r5RX}<n{*TuY)WvAjP)fFLp7uWpDBEI>Waa})%bL) ztVqbtPC`V)l)74y@oiR0Y#h7vClqV*m_v`ONBFN`VNbrDqDjII)6w6Q{u!&t_Uz%x z&Z^>*osS3#Km~^!8L^@}56N%3q;LUSm#-M;m{Yh9ZI26Sa&LjT&k;;vujTJ6;TK5` z=5t~RjQJVY=ZI%<9PTccTpp`0KeRj;e60lAER$)c$tNbN<dZKz2D|4%Dk&iWv)Ib! z#Pb-tRp2v9*ao4aF<Fmf1?xi*z_{>RV&c&VW&H!|YIxzAjI`_|f<_DzjRB=8B^Fvz zMoLb6aZXpZ3Aur}d5($Uo@7^}XPwunBofKRS~ZOMWGtx;1_($kG#|7^n12R_#H#<O zS8kEotdF~MBv0u!l!{r;g4aB#=i_ZvZ~i)`{BaQ%I;VCJKw{Jj-2j}3-2P(s^1)u< zwUNwn;6xR8xKC+!_;WaZhK@#_m@YQ4h5CCGiU!=I=xQcQ^cX<?xV<nZ|A>WkebtSZ z-vHnoN1F@kMV<?Vf$piE2Pl3Nv_v3tyFIRjaRH%v$y&1+1v$C1&BV%3JZ>F@;_&Yb zjBn4c`i6#RC@3OvDmYmV<Fz4I8y8j8)v(BWB+Y3jHaB*NXJ>?4cDDA$i%t3StOEn$ z6waTiX=qYglirq$Jd9<_-U3+yV7`NbgN23rE*b)H0D=4HM>lBYIz<p|k41yk2NGyy z^D}z2uo%Owm*s1@hx3S8>Ya0i<+SRc&e&NMSaDitdRP0FfY%3<Yg}?Go>Ab1_I9_0 zlZBOE?S#xsh@>ImvOO$I$F;Pi80sREz`4$5u{E&yz4B|k#L;<Lnte}8^CK{`dRQMD za;jGxN|}^gON2$7FD@<yrITMB1R(22MVT^IoDbG%gVV7L@Eq~>_Fn%TnVV8IY&^Q& z^Mjixn@G1yTx-=k+(NAtpZ$RS^*;#9uNqy>3u<)$0$%q67&R(3R*~_aS~W1)a&8YF zFV%T?w4|hHUs)YCT|VR>?iN(N6-Jf$h>0m>6C0DFZG0?t$lM!tl7h39$ihB4F=21* zP~=w5ku^5q`H+-kW=leBB1&Y!tEW%4@H%u9Us2Jp!G3!jm>TT?O*b8#H3EDBEM_ak zyX@@{EG!IteZ$1W_>-codKbIH(do>u%EmJzYv8h3Esc#ehAvtilyY*2qq!G4C)rqu zn24Ar+7~5uK64$#7l(Y1#dTr5u)|V#ooyVYA+5car@guPsGk`~?@kU~oq&-^?h6BB zUkv=BfeJE%;Q+|k=&w>cao?XcALTVP+#Id&I$syp8XO-7z64cVi}AX^#Kx{Ty0rq9 z)-1rm`pmaRYL>uq_(sEGBI6ZJTteKDzapsc08^k;v&Vd(8O~%nd29oIE*MZ$Cnj@p z6KqsW%$v2LR-xwK1)d6H{dTH#R&<3h{6ddKH;G|vo)`Ty{J}A0n9ChI0lf&OMC|@i zf#ne?DHdRxvw@rmEdpj~g!BkTyz-qSmNgp6{g;}uG9i<}x~rE7m1$`_HaZ|9;sf{? zn+VOGslq>3qY@)UhqBYeZE#0EZLCso=j1A0XPf_#p6V#G%AX-^PoL2{#ne4s-UX)$ zkP*pNcX{;IjaKWHL>tOISn`ykc2*YbG|K!F*yV_xuu1mH({Y53E?$xwwFKBPY^A&a zJ!G-7kmp=~flW!io(sQG;u9Al&mEdUk!l2<gwzO3@=8*1;o)!JyVSu<sQo(^Ktr%5 zOr)%-`B>T-GwwQT5_r2eCi(mTR;}J;f2Vpkv+E}ohm%y|WbqgSJ+)@b-4Kv<PFpEv zSt`1?drl(Q1(IE0AubdeN&@k2;0cGz?Lp7Z9)N2yS%iVt8<fa$SP2T3vcJn54<A5H zNFtu2r`fx)IoW-DEw8OD4S4=+tpAXq!oo`Ii6l{Q{i9H<Lg#c;mX(#IFB;qRrxU20 zGaIjBprBL&LuMCs;)6#f?Yl1ubU<O5$dfvO?HrN(vD`|!FNO+u-2E!Zc4jCAfnb-# zLi%j%X#2x1X>py^anN2rm0JSP1#`b_=~(MTV4!oRzfyGj_UoWI03Mz%Gm+t#PxOtg zp<!2E-rpAip-4C!JRj4@KYhCTy=@CL3@w}KludfNB$B{>_+hmG^sRt?E0DX{URW2F zY@TmwqMCCsH8T2%$Dr2jLOKx?`VLWVb7$vwF6qN47uO;<^$lv)=@{t9XmVU|px6@( z%s4J9ebdp29mF9QiE?Xn5R(v2pW?LoI~^y@yWzkbHIe3V53XUX(x?L{C8lr(Uxa{O zDH)Hc?v7+f4>>ej2HBts(v&&Rztex;`H-FIt+sE14h12s+?3*wdNE0XL4|ocpbPDD z@hts=s*=<C#IDB^mB<7S8v1AXuS2c!zmfsFVns^L$-IbwaXa9j0>qPXx!mMHzG)!Q z!!9yGyZyVR%h{gB{`ITV)}&gKi5*p-6bk(0Y#OqoCt3>?PZ~I<>Seo%RhgSl59Wa* z^7M2apfmA17=!G`<7KKmgg_EtcT%ajCgb~~cbTMonZJVq6rRK1GFvEF>Y8?tPc$l) z)-J{KzWeyc+_Lp_@mN=IPyZ%|?AOFZy=~V8I*lf$)lnR<+DJ|o|MpErz#v9a_Hf$w zYCQ|J=O7ShTmwG<cm()+!vO&bQ&9@rQ*iwCU~P3USB2+IEX>bG#F%#69c%`z?=dAx z=88j;Jeo9QtVC3tKHn2tnq8#miRzJngj;WKAG6u4g|V@Pwgnmv){raB#rDL)jd2&7 zsVM~j>S4cr)gDZnS_;zWi)-sR@uBMT;36*gu8SVWI@na@Zs2ixm<&C1+=YN7?C(w@ zFcLt)r|Rgq#3*>g2KM5x(z-7c<OUcFX)g9gqRHCYhPBqqzN$Lz0fSBMPo%$l+CKw_ zW@;{PE-pd1TMu^^dVQ?n)~QhSThNvmd*9p%0;;Ge1u=6?UY?SqWNo5lZcqxK@l55V z-!{XAIqb7)DGvE_h57!@idrA9F;|i4?SXDlX)(rbxBImSFw6EbYBIk}mi~ywLhZ+x zBEG!@DPtBE7U10wwE$R!Dbdl{^SQe_f6sQuNJv;%C@Guj>f$7`ywXIcsQY3cShaW_ z6Z$<@*M(E^0b_OVzTUR4S7dt#lAHLaHt`U`?>Q_Y((GGVd3vPH+tEP)fxOSJp}7t( zF5d5cM-W5v*vDtkmRZp9Zk7vxb`cS=&>ugpEI&6i0LGN^^`%XInI(Xx(h<^fag5gA z^(xasab`7J9e~IUF*o<p&>@(l1VDURX@YfnDl_b-ObYPOj2f>Rt*E##SG7MF54Ayq zhcq>Fg#3KK(rg0la?OAPq%0!?&*>*aD3T164vmlZ(zY2TeQrvk20_<~G|0XW(2!bL zTVoEF=4Sprhm(~V#pQypEThF6E>$!ZHs)K`P56c-wZzkE8jJMpl%ga;@CB9mepHm4 zi#mK5L{`T551WJ0Sha1JNfRT=rDWD8$fBr=fQycfje$W-Ns2`yb8&;?E+s~}pYLLB z049scC$(`&Rn@-sbPcO+ca@fi^z;y&4#n-=T~^i^&_X`q@l7)8YP54U0d1~OP*Eh7 zaO)>fEDj8;tS#NzBjkiAN*y(W_N1XsT$xOZ1w@3w5AI4KWIxwrjV^5+Z3hPWL^IE* z85lSTd=^p7y|sB^m6RV?WK{LWb>Zs)r4t)F<z*Vk&wW7ukexjyOCQPiI>%+mLLHOY zs?mOny4Y|RWR=XS9=^7xqxLhMTKs}eAq|ypiS8fLiJ8`40u6K;npUr74&Y@6+ze16 zA$cN)>+7WQsili|cP8#~N{tRocn?igRgCoX<SZ;xJu^My>#Ug;W8Bzbc-%-xuLHv* zjg}7<vjTJR3Az-|L-h3Z1-tmhnj<qalhvC|kh?T&!8u%v_faV!K7q6X^Uoh5(3uQG zglA`Gqle;SV<q1~@&n7wTF_h$S5`7f<`;I5apuEHU-(ghu8+=6f#@P$ZS8mFbb!OP zWw9utq4M5+62xg2mxkW`F2&cg<DlFDa`QzO(ng|*!2r^PGSc`xr*w%ak63v#PX$FJ zCW$R6)UrfSNuRXQJo>{;g(Y#0PmbpOL()O%SF(Ad2b7%!83hyq!r~~Bk~z;w3up_q z)ClYU=7OFGzmivgqAtk7znNtp-taL)jg4)#*WLK0h>9xpu|b6J=O+a?kSfm>&{GUo z{_uhZI>LFZPg2*=KmXrW&(I%nG&?devX&WUTCk@1?8C<D9<BELKR7rOn2t|xSmtm4 zbO2uDTn}pv_w+w`t_93L*#En41<e0bP5ysbe*XXGZ)ks<X+(x0DR2*4Bk8fIxqKC~ z=!!yy-~0xD`t4V_z2-}s(jiy#sYU5`(&fX!`0()GMIy+XFS9B2T?Y<`ogei+b((3< zh&#ZY+{eYTvr|Q4XbkpS4zm7>{E%k)Z;Ht<)4$m$e=+~fGO-K!H)X#;0Gv^u9_j?3 zo&tS%mAv~8Ljazse=}YF_kCP7%qy5Lo>ZFUHus(4;D}PYM6Uo{4Q=&-;5WJ*w6*)- zd9u+lFcbn-mL&IsQ28n7q-=SZY@`oE5ajbUB_MJ#QEJu5><pQ$w!lF_+2?vZi?^LD z*0e-oeB7!x6uJJZ+ZATHP{T>uT?D<p<s^P^&0GLhs8GeTxm|U)+}~m`8%f#WHZGJ5 z5U9PtCnB=n*TmYSx!QPWJS>tg%rSLKA(Av;NEFV2en8SA7?!r$B8A=A^Idh%5|!qX zX_&rj{z^bIKetAhk#`a3JZN@REKwm)o^V~`^0<0%H670b9bMqEhK2*#VXh`?%-I3^ z1rfHx>%o0v2zavzWy$y60rX;-->kxX=pwzfE2!zZJHXQIq})@$WcozexWY5hdgf(m zoG*KtBYtzSQ~q~~Xl7<cwZ`0XD#w0i%Ia&Q!}-o6XIxyJ>k#e+V!pAlZ0-<w720H; z!u<|K09Ht@g#k9VwazhwdtH{0vQV|u0({b3mB~uMDG+gI+D`0o(6<CS>_b3YQBg@s z>S+5njk$yB++?xK<zD*J1r+81XwMaum{@CliiYvm>g#P@ULJ7>X7ABXWvNhhwh*vo zshHa@&>;-C(V8botTh9+I~a^<r4-F<2~lS^s)zUGX4fNGy77PlS8I`Yzi?UFa((ge z0<H=JAODyB9dK!ky=xiQO`dZ+m~Sv|6f4juPI#J{me47R2OJJ{wYAfI`2Z(7f}7pW zh~_Yn*>YfDfV1#i&p@x+wff4*ryZbZ5|T@wur54yj|>&Jd7sM#qWA=6E>2GOc@h-8 zLKC@ChlT1Xj?;A@r~6U1)*|`t?z<${Jur*WGq0gerjEJ<2%)dfy&8+zBIfc(_m9ni z1ajyI95I6hb@aLTUJImM@PBngSJI^sO1(F}ulCTLQN(v1OL4!|PZM4fD0og8a~IYn z`Ni!@$jxp3#pMDH&WHU3M#F=!{Cwkg?^Z77?OkhwX$s^S(rvqt9G9$3&CJ9=!7z+_ zV9H?EZpBaKdyV#(!BhovqB*cUqaUvCcvW%(j!<9{^Q#%dc$EqfR+-jjP8GbFtDWlP z6A)lDU;QV&cRGlcg2MH_Gh~hHp$Z%eA(<d`ly?Fd-y0j^T55dcaZ}s#xm?6S!P(~m z-S5-cV}91<<t4xa>>a>Jc4C}ye`g;+H9UVzzJ&38NsmJ>&z1zvzosBea@`BB(jtvU ziA8Z@-CGAwI7u98qgLCg6m{1NpmN{Y`5RT;Wbl%^Klw6}dCx^7JxftnAjE7NA*{Du zrze%xdh{Z>Q4cJM!=S)xL6>Nd+FAWAj4@s3T*yk=?LI1CC`BZzpE5lBcH^IVKoO`} z10le8+gJsM+C0S<a{|~AXF^Ay4Gy{r6%|UYUn(gSnxBlvmqf%X$IrGUFzv!3?rH9Q zFPRtd^?k`=GJdog%%(Y9l3d_5pjvD4f<mFB!K_>BQdlq)cLJ6$@5cCn<_~p>-JVjW zKcZfm``l#v*RRfKeVyW}F6Vzsum@hVkQHb`gVGTZ^wtOd{>>{G38`8+#|qv)HknAM z{sXIo!(Mf`R2#;OZNyA6o~O|sI>&^#GeuK{hRbH>^zhv;WM|59q9EH;=4^Y@m$N~A zzQO*@W%eF(<H)j+nMUSKe%G6Xfla_TEm5mBSnC{kuhj!DD<h-tmK%zL3ccPdSP;Fh zd?!7m5b)Rtf6rVE>TjQLgMKN9-Cmuq8h_%#1TgH=Xm048J80Yrz2oSkuxv?=kGS5L zicEFeTbZ7gMf|w7rqj{U0U{6RG6kxy?w#@g!b(3cN^ZQ=+gdl9-ZSEcs;gfPRzknB zt?@6AEo~Yys4UjFj76w2nb8?;djQI$ww?=)Q#hb+K;CpRU*sLC9ug9K(9fVI{XN8! zk@p<pS8g(3g_^=r3C@)n+XCs{+wX#**y=4h*-b?XiKcoW=->}HaQ!<Mkdc|O&TcW& zAig`${AM2xI?`9~!PPn~0jo5625fhAb*Hbl8L|ZUzldHyyqR_%ARmR~Gjc+()Va*^ z5U|C>lIV8Xr>?-k3Nxe=+#OA^n~Qa@j@N7U*}3nWe$~q6md=&6>)kXqh5sXFO8aGd zw+CVsFP8I$I#25HX+cS`CA@v^by=)Yslb}TZDcZ4BVNv6SC@A0@VcI!0kmbz6zIQ^ z4+NqQm5zrkA&Iq=>Ma@C9kA&34st0F^SXms(-y!|>BtcWmft@sluk}4(c)22aO@Yl zO*s@svt_&dTB%3;!TiHO!z_3jT|;Oj)i5YuFLh)tG9AfK{>$y!Y6F_%i%0%mz0%nD z*+4#ns4MWNtE;CCxYq&-@A*H!O3>|FRvM%y3jXw2xd=35DS|z-*~QNDZUs)gS-*a~ zK)w=el(SY_66pVmRzyAv7K$*-k~TJ{s3M>Iz5&B!V7lz&<RoTiM~2MfwzhwHiTC9# zF7CzZ=_ArVX<SfjXr=KQ@wI}>FCsXYWlM+>KWjACoWFP?VKc-V91<fno)!aB2^$QI zq4wh4E8tYQ(4rfF5D*XmD)s8nXT8FpC}Qx}>YrBA<i@Eyjr|%`3wR`WpnL|Rmo7jj zUX`?F)7j4iWI^}M0$cUKCeITRX%QqjfVo2gVFtXAXZvh2uu&TNf#AXRe;cg)&!7|) z&odZu#jhS79$#AqOsZ%092&)+20#YZXT*r{qd2i`+MS_-71UWN`nN%)GME`L98Z_Z z-@R{MD6d?sP`_1Nf%p~wixNH6D-dDwUOF_Y+fWFX>hV0-q4BnZHwEEG_GCfFfQL1M zqRafSbz@^ob$#_K^(O>apA$>ieG_<?J3$GH>`8A(Bk7E?vY(%y%(}0?zW^Vf)f?!y z@oW?J^!E4n_w+=}6Mv$m1?ux!dE>i3hQqb8Nx<#6lhB7%vUx+qP9fWy($>~CJ*~8( z3cV0g_WCp-zzrrP4R2fq8t2i@U(;)0f>pq6q=2XzZ_5h_f$fdsiB;-ViI4uyhU_ys zYDw^$jZ$cYCfRdvbx?>wrEPC-r=z1|RE8cFq>!Fol*>>=*=jB%2*qRCJ$;obZ$RxL zyG$Cq0!HfD$jFF|_2<}+HtFeiPvIbS9t*I6qE(5?%ge``p^!l8s|qKjbL<<$M>8QI z@F|P2v56Fiprm)^3ccnwis#RtufOL_Zkaz~0oS;!?~q8vy`;m4NkBmGw4XT%^YQtr z8Fg>?FFcZKgDLA3`vDcL*VW33HsmP~IYneCf*&BEX?OJj8vHAMeShxEk~jbl{f$i` zjl;v#Y<D7FfQl47u~Ucf{^Q3y=re7F&!1xphdPX`C_)FJ&&;9GM&Upn`qZ-gtZhbB zFj6bDJfx9xp4)x;uWw3r$el-Kw<c-(7Y_fza<!kCVYKqEf5H5(<8u}8t6;Wb_mbR1 zJ+!sb$nYtJp!ai&3tY!={@(oE9=7QAY21q!83eqjb_ARZG!$Jr)w4U3Kj}V-BvDZj zun<s4!hZLef1~5WIt+sRKk)FhXv%eI!wCr~1TW~M)4iV&%NMALewDCjDN1@eQf$FJ zefY0$swvr0*D@a7@;dYwKSKXuj(T64X13=L9T81&h)+VpP5E1GH0k<PZfH^0=fO%; zqZbevXFW;gMD;{vSm=JYYzW4U=P(0bq)7L}TdDLCgg5ZT@q;cr5i9M)Cx!5l!@VAC zYQ2NyQSY_t>lD)>^$N;{6HLj%qm81A^SGgR-{?O+;roZdS1OT`kv&G$Y$ij><_?kN zRcf?fNO>}N!y<sRlt~OFHHLzg7u^?9Dd+%sgyjNS!SbugG4Z>CKmMjnYlG8%%H@Qo zL1C|_ZrXbLsfVD=XI`hsOl2_391)sU1(Am))+@aS-CUH{uVUN^U*dav=j8o(YrDL} z>6vS|DKfjdBfhOC62tr!2jjioL!(jIoV-FP|M!oA=J<`AZF=ypk$(PzOs!J>ce+Q+ z+}c))M*t`iWPfT+qa~ci#zRrqo9u;;l&uW*$PhJ9(2&sZ?$NW;^fBXAknnJ@@X$`5 zu&C^ltDG5;bRJ>KGGwdy+KE2b(_B+sA*tq`$KGnt@>0@zM7zSU!j>u&u&PxDb|MV- z=+ra+$UmzP_Rc8_|1{H4@hjs)QSUD!#%}+fsoHhddK5<1)gI%S6Y57ooy>0wBt2dF z`dc4RmCOfmuuxIoVxVFFy|B!23xH;LkKn<L{~|eU#!Ph>4)g6fSx;rv@%wY3v+zX4 zPu~1j+g#Cl1+HIKF>4y^-%@YOkM{ihKDH}8$W;knQ3M$$lnahZT0LZyEdBaEHBwpm zL^*d#O2={S*`R|%g|_lcjLum2PqVJdyLonRNHtM%zugk9auY2JHwnwqePQzK`1fx! zgu1Ue#~N!c4&^_vU8GDHL~+`3u*%kZ)>cH+i7a+}8<=f;sZ`OZXMRwxJ@e{|Oy%># ziz#Z;$aRSt5&ZhUo(@GZkeJ8~1<n3)T_V!{o>E-&1CtJ=P+lm3*y#-bBuSQF2gLr& zM$Z?FOBgHM*ByOE!bCtq_`66xB`6T|sn>2O!;doHB-behBeJk{HBK?J#U~IMNAU5i zXt#g<>P;VRNCsL^Bg1!{Y-043_{TmgC$1Buw*xLTXvGoG&6i|Gcw;Xo+uK9&gMz*% zf{1mDD87s7gs(S$|Mi>K@ULU2n0hZvC!%CVZ3(3!BMNlP6mA38#gyfhe$2e%z(6=) zRVO3NgU}pIFTd@AH9arGMt1Hmk9$-e$^Tiz+%?7GaySz1nXBlXgK7Ovpq9-JL)lry zg=lHET=#`og{Xd?{;hs$pSQz(;g`ApyzalruE_+*5GK@+FYv`RzE?t1FW`xUBVv8g z9U3#^6DRY;b6`nzSGLK83~apw3we5)s)FPubO_Y;_rCW(OaJUGOhQixCoIuNJu4&D z*~K4VTpd10I<G7)3}L$d6t+i3G9{fTY=feBo*h+%T^x{K7k4x~#vqD@@(!0$Q+yO9 zP^EWqzLb!;QT>AN+;fv%KfM|+YTtU_p~w1U%(lYzRJZqoma!%?ZP_=?u0IhPMC_v! zGpQ461oO6ZGuJjn(WlDKO19K{%~sOC&U(j`RXBu@n%^noK)biwf}i3<Xi7J!u}sL- zkBpFd!VH@~q?qx7kXM2)+J3#Sq;+%<yj%%QP(EnpGuGpzi%2KiW-W2}e(>%4^S(F@ zvaPWjy$(WG$X*8nqIJo9HF+@wHO7yB-oR7I3>2uCU9ISDhaQwyP;H0?&;Jn!NH%vN zsz^Q-SL$9`;$-}t1fpq=^hnXxPra|j%(8VXCJe&lTSBGqMHRI~iKjvw)i0<bGahF0 zqzoc)T-Ti%%mNEP>&+lT1x%LJIe=Z=Aj@W(j)jC#go1W@F{Npl5`5KSA;&012wJ?b zmYmVKnriuJ?6)^YHV^TB8|cPAV+>iDc!jL!u79kHe>D(nf|!y?eb8n6*emTVgil^v zNqz~_w?0kYwMe?5><f8W->P@ZP)Q{iW@v<r7e$-5>KvY>*p-YE(34J<pwS~eS>RI7 z)?l+`@-jj5$kEYQpF<D)G%rVKV3vMXqEQ@Inb11zU}z{`*d1-FudiCwz<;2oUMXt! z?(IpKZ}v}4fqGy6IN8xk&V(#|_KtV2)zpeH)rFOX#dn^+5DN`}jiE}R@Eeg!;mWQ0 zA+kyyR!VamnOOn(t?J-f-mKgcI-U}s6h(=Ju0CRT4R8@r^Jv3qSQsKrO?5tVc(m?_ z%fgA4Ev~6@7@K-!@y~z7D{5%GT9V`VX2ls?X5_L_@)PmB=;sI#qcYaHYr?OJm>K6e z>l(3wg-e7Cu~A`0>Y6jj5#i}J1gL>dRXaiudFL$Yk}Mh$67D8n!&7Io;vOS}e`x&z z5_ox=hS4Nz2F_`F$Ip^70^|1WjP&ifRLio?>+t^4<37!~Mlk1KQq<@oGG`^M8K#rz zlzF_+8+K#HJ0kp(Jj#+^QoqkJA`9%g5W*$|-&*I@EAQGZwf(`1Fv$i{)L(v&Lzy*3 z=LKc4a_UL8)Rrv?xIZ(j*PCr#KRhk`O$`P7;n4TR#FM#(aVIW&ZMwnzfH}km$im5n z$?pvUEiCNSMY@ejBg5|Y;9xbI=KIDvLFtnH9>)tl4Lc$g&diMSO0(Quo_pP_;<$*7 z82g6c!n!yemAj4g&Av;>00m{0^I4H5YZVDU8mh`ErC<Iw>n2a+Et1{75(cTO<f%UE zzvjRQNOvtCc3NO6cCP%r>%OeYF;rn-++SGRcKw>$_>m?*C=yMp{*td`0*RtVGdHN# z%`QSo<km>I^w+n3e$8XLT$hyI!RkDbtsPlSX5PL6i7Y+kg@(mgZ1Of%u2x?hRS3g> z4r!Zby$jl9Hx1$5Fn!VZPi)f&K}3-tyE7;MrXViAGPf$j{f+exN=e9-w(d7ebGA2u z9^3Kt{A;;UXGvpWZMTiD6EA4LzK(+rI@?Q$h|g`}EA)^T*=3!|DoS#bxBn@?%VcOO zl{i3+5#y?o&o4bQn4p6z+S4yg!mz=!g}M#D|3A#G;l{h?rr<h)I30&3@~9Z*R;BC7 zxQNAc2m)zCi3~T{QZsXtnx(*kkRUV^4$`AfE@+aq2AHZR5gZ~WU%Mz--^X@!vF*I= zf$h!}P03(-K}lMcqrYy_946=w*ErQipE@WDk*!p770!rRAKz=LEHamaFFscjvNA5- zswxuXnq6%kqJo?0C?DMPJsBb<qMBpkBF9%M%(JIw>N}?VHI}XiP{y{NKnp`E`D{ZO z<-gVj{z2?>uSKF{XheMF`YiS%emC}~_MqL@E&%WkwMJIuxw!F-28s!Lg1bRxNQJ9- zC|#lDCRTTnQZ}4+cZO|VH{EFj$?0L?boeE`zpH0b17@<<dmM~aaLY^qas<-_GKI~W zQZm6brt>`K=j6Y00d+gpD_xP5a(>3qVdL?uSb-B&;?YidCWT}@4kDlAt9UO>%5BmZ z2&2S=ZLyzU;+WEYTu=R4j<c0|91aC<8hnTVrJEj?uV|-lwxYn-=6#bXz&w6eGRvZp z_j%i&kN@=qeu#jj!_0oKSHE`-gpC~;!Aj2rcaET4L5@$T94Akd2L%Nq!~Pr1NOTk7 zL;)JsfrfM;e$|D~`;g@FTU#oW{+@wa*UH<glW?g+xwYXlY{d{-*3AlCBHvM@mKt6C zFC3J43g<$_bOzG@u?%WI?#X*FBf>;ekW87XfBHZ{#l*j83i3gNWDaohW|Ty0Z;BP1 zV_~`*>!WW4330`vy{GerkIK>*2KVxA^=x0t<j^zfM*RgCcLV_0ZrhW=v|*D-b=4Rg zSB0m0reU|<{mwZoG*G?;74v=3nJ+P5x4`+dOUVdI&}$TSt+Qh&hssHhmY2~p3b4=Z z(_tbI$rF5s3oPlKuAceVPMG!`8kOYxA)0X!@rBqS58n~TBiPWbpwUCB=jpjkK9Lx{ z&nmg1&z^O5TkV|u#d-R6`R~wV{V$7Jth@vpVn2xQw4Pvocv@Rs9PLHigEjO63SyQV z^d+E29s^p;eP+1GK#$nLG#5ADs5WAhp)z*Er>`l9Q8Oal;tx1scsi~L__Xg(f)mtW z_q7sF!BLXv7id(G?=y)mL<BJ6gkk>cD;OGvc<f79pe*yR6P-N3Qf%)3K6pKU{$hiZ zhDH)PH@p;$(J{R@c{ZSDi?+V!FC-?~$SEo1z*8{GL^gri<w{X&&co2$L=fG_5Chxr z1&g8n{{MJ>mdIbt;%cQM?KODw?RVa9QI$3{(O|#;IP_CfTi@I)7>mVv@CPH&G-Sk> z;Da4q2549tPw5bzS4QaQ^lwp7DPysE9|3>MOVZcZ_oH|YAb}OfJ^eVF$UgY`I@)-# z2jzPa=t(r}BSI$-Bk^eukBt@Nq=bIa20`q5&>&-@tj&no@r&TD?2-anCc6q6F@i@9 z3-_L%p9kVBD#Z#<`2~IAr^EPm^tu8><JxfTYlyeVGnhMZ35%2hKITL!c8Wd|swjLC zP~XP?RVw-<*{1(_>K%~RVr6B8%)Na1(%0Wl@x-3bNT_TS1#Flexu89Dqv$L#)v~8= zV8X)&kl4Qa_zW0SM)43w3PZ2s$knD?%NE$<4fXX^DpB^1Fa&=7LipPZso*Zg2Mn#$ zA9C)t(eA@yJtZ{J2{({Jg7LBqYPa5EC(J5fYX*Ci7rBD`vlvnDc^R_qTIFKq#5L;R zr$g{oWZF*wpAkKJxL!{K`Rp?^tyiQlJR6XVyk1cP=<s|A7>=NY{s9B?U#P_OKg$~Z zA1J`Rk%ECKWpg?E@icat1<I8%CnV$lkzDX9!2o;Aa~MxZWMb~qfDyfcX8gW^==t>c zFIg=7zciBnUzO3VpY!t^e)MH?`)Hyq&`};le+Ca@6&fyw52Yg#G0_go)k-9L!93m| z;$8Omn$I~R)axy0`@rS*MkgdNJI^9ud}K`1jO+}FYSA|^Xa-fy8P=KF(B^dc!Wd|V z>B}WF5J;{oQ-W@~QpMVm6Ms;qT6$EvnH$xIWEts-5zFqYRI^oCvZQ(9fvVS_Bj7El z)|$t9Pnb?Bcz6*B%HE!VoUu$pt?gw>)VLgmQmZio2pQnVB11#0I}nV)3wxwI9!_^X zXll03^e@n%r)TJrywZ8}esMrhtgzeuo6mWFq1JN`G1JG#Cz_`DelY;lKN1*C?7GZZ z3Jcz1V2qSXmbOk>*6A(7Y)H1;YA;r7c!E^?WAhbWm@p^-CZ7%HVbtx0&S5g;9bzsu z$Oi+ixPDgd<=;tpXwcY#rag}Wd2G}EK)4SgB`M;xXZA0k<v39Mp;s%lSO`UTFq+Dd zH!|vO_X$9|znBAMLKgFxlSx-RuJh>*h&5?y&p;m#au}#olAEaC30gUk@yPkqDs|yj z9=%8SJ4%>dt5Rj53l8qpg6Ad9)8K|dYq3t6T3X9Ewe#Z@`nFUZvz3mi<fTgwN(I@9 zpy|Wy?}IB4=#?*4aZ^%?E4o``w>%sx%oSs^S^v9PJoi+XNy>eyh^Ff+t&w<?o6m&? zjd$GaUuK`+qUyES?;#r4rri?ywA0>Id^VlBkB*P7Hv5wbq{t^bhvLrFM~`*ScPDX} z?S9<enNB{OZ|zLbTMgmV)+<iU);agxAYvioaN+OfM@wZ&?9aZqy?ld=JeDJo%(csR z(gXT2F}Oel8xAil);pMHXS5s?2$=|Tv&?21%r8L=`f@)kBm~GEmp6gR^5(-fS$_VK zL^Ab@SOo01=qQy}wcIs_)5bluz;1E*WQ8{~GsB#DZghh}p;Va!Pa)IgoHzsv5tp&8 zIrBz%No=sY-)yAm+N-6xnhvG)ikyU|)|WH)pkW{F5_p!+5*RP9bOgM4Q!JjyXtG#j zysJ<Q3c8#z6)NVQpsHnap0K_?e)OZvKs*5fb6PSvq(d;2TD@j<c%_3xlDO;7pNWF# z;YLBwKW6IOACnw;+BLcXimwvlqQz)<G;;*5{mlVRGgyG1$mXzDXQS|Y?82?N!5+x- zR5(6*<89G&_xF!(CNUXW7%2&P(Jhy-TSyN+{33SWHBy;wl}wGHN~k|91P)T?e<#h4 zEn5$#>qTp5iP>_TPl<V95g8K)a1$L@^-e(DuV#PRgK}Hx9FythY-h2?e)swf+QDKi z3pKIBSv7&0ZLjyO`*wwT<0UaJo1T=v3X>_b@o1f7DK0Sz3zPu2%miIq6d#7l2;n2J zzrs@QS$z$`oCqHLRNoOYrRm7Ho%d1(Vr_&taDn%w?6IvqBV>^9<7|t2u1(hG+)kmh zPcvPRrAMoQN&*7guE7P<$&%Ln7%WU?qffe^&CBZVu(o-D9`!6{0+Yp_%6j)mmgC~1 zlxZLF=;#<xr^-=ZhA8ZXqvSAUjGOsPD7qT0dncu`2v#Zt(%`;zV3^`&w^OAI269|L zgt5|fXpuJ{blziE3Dg)uqUh*t<@B;Z(Z=C0Vpu$yrzP3cX(GWs5^yfS?5y_eDHJ$2 zLFTqfr6?HdPr6V3O*adzMCm!0PTt$E#f=o0DU@ii>(BlK&_Gs`kU|uxt6;*k6fDUD z3r?90Wy-^_+uzl?z?TyA%F#Y<`Aw<0^B<;gRz-A$;Y?k`b-I6{py<0F@(B#=ohfa- zFa)JU=4#mhq!MR`{TTw+Y8AD+aSVpoQYB}TQ~FB187kFU?&EHs1MMXs)VSnw`9d*x zWW4GO=zk$$a2)-O(5={1VL|~n4gKz}uapqmE+m4{b_^|$k7eGYPaIUep~2t?78-#* zhav2y-yY<v)=JEvOWMJr{NkiP8m{1HP^3z%thSx9plSxJJ;|j-P9mhaX2-)Q<4PdR zV>tNmP#%C6E}U6X->X9w$6-I)vmV7}Z=%WS7WcCV4KKI&>-%hE?gzQiLO=^&<ZNWI zoLs*Oj4YfgnyYaS&{G0r02Km3$I8}JRff+io8>Go69+s?H14NvLI4;mZ9NA*HnVBo z_o|Gh2{9?heZTkxBSXy>8{}6Zlf|NLp^JbRZB#uw-vv55-LEI~3MXdEd0iEY@vjq> z#8P(V6$f1Hb}`Y>YYmrLQa2yn_O1etQHU2k57lkrs#q28>P19L3Fmf3>t#VD&hc=e z)@B2<xb%zx^4suad>evSZ8T!`J6iGgpHA#AaaU`1uONs0arf#0Z)OAh{YedpN&_|B zh_<86R>0oIWI4-s(H0~hs!pdn={PbX4LSy+XMi-)WP|-K*t!6vf%y9L!Sw)gNxOxK z$(%>yc`(y>*>%m`m#V1F+T<{xqVJ5hkzl|36L#&WkGfd1bg^Wi5zuL<R-%5(x6-IC zmO9dy;|f=ErO-;Z1o!CeO;(#0Dh!;SoPf5Eld(HZ{Do280m#xrxTPU8oCf>q4&deX z$w2oZX5E@~>tN5v`;$I@a&X(*u%JhQ+Ifbe%dQYS>j^7if!;c^&TVELK;D<*0^Lal z&S6168+5MpXX8NV2w1|daae!_m^?8N{r&s-w3fAFht0H4&d7+bBYyO{BQ6cyN{><t zvspCi7hQbfh;P*mc(ds4Ilp)hNBC-YK3`YKDDM`3L|XcC-q}?uoIu?KlyY}_4spc9 zqCSbqd{p!w&C~YT(sWV=-7y?WB3>Jd%Mz?}Ga<Kn-?J3ohpV&fs7zgzwpjD2fDXaB z)0)I;7Xf>dsRG$TuoZexVv$*^0#3;&S}L7uC;}g!0Eg9~y0o-(D3#gF>aRDmt>*`~ zx!f65QAWEp<`9jZHYP=NvUhPcek@B)9Yb$CqoYZH37<dPYM*@Jl-WX!`o+<JZdW(b zRE+sd#UGOER8a#K^G<B4HYh8n35fG$Bn|K{T)Oc&)!oHAHeBm9v)OYv@CyU#O8{=2 zBD+iltT5H%Avi^xoH*>wZ_Hl5U+Gy1_bx1LYEle4nC%_}s-gjcb&8W?X7lS;B#sQ` z3;)gqjF*V-1~+_pkPq<nJ=<HPA|?I1+|K*?Yl}NQF}Qbk@b|JXUPHzh3c+XtHa51z z?hO1V!SvL?V!jx$&V<7`b}}C{PHX~dks4?#F``{9=qqipsmoF{RO|455sI|QZr>!; z$HxdxE~BIyKYib$AUjizrn|dRHOmaD<rqiG4v@2}1ATUtW9xDi{nWKC$vpZ*_QPBN z<X*zTi6(5`{L?cfGd8NPrPb<jP`r*QDx}bwVq>5;`|+Bx$5BsfI=o%QB|D<3@NnBI z*KV^-7Oz2nZpnQI5ZC$!qttf_+-isFYj?*>_C-pgY!%P;$3*9}(%mv0id7B-!O^rx zo3|^KB$-N{2@aVRN%>pIPRW^w?Zk%;HP;EE;p=E*V%)**Zo81Z(fmxerLQk;q1p&- zqEJ8*{dM(q;e>%=C_G1fZX+67CTPJh{{%oOHM4^A+^hXL>TCD>2@`iv|4(GJ7|&3y z1acoD6|$WNwUxFuRn!e86K8wrO$NX6$#vn7iCxY%HSSZBonNtG-Sj*J{=5-9tr`wr z{`nK(b-Ub5(Nf*<mS1ac8=`K>gbBAXQj)|la9FWfmdd!rpB?**oRYFK404@3AiQuY zXw+Jm%fnXe0^SGMuM7tsJZ4U~0Sj#=Pud57gSya6fm^`h2-qCZT;}3pr|7C4*q?vT zNJBmw>!#<MnFvPBGZGG&jk)x%1TALB`vzwYnaVXk93*05TyE>tM-8WX`93r#3Yo35 z9(g+YlmCDwC`!u8jxr@l4rZ&_yUkiQ3n*v~X0w03F<JTk1p-Lj!nzygh**qur;~{W z{SO+?e=N9O^op`3<_eQr&t%8GgiB3_jVUC!*qPv@`0?shd}c-li2lf_5`Jn3{uKr? z`e0w5o?80iQV+bJQzst=H;z^aX1xlxY=-p1Qf7Z;>6VnP&1iar>`cgG2uYm}-(R|M zIv4EqK$&KyBdbfGT0L8B%xTw6q{4E?oVee;?G6oRBGXxfQw&7S6tr!X^DG}9-YJOm z7fjqndcyLfJKp=)Z7<cv4w$IdIR#QyYp)SI_Xu6*0EZVU-D)N<9nvVDLR{6ENmpnx zWP9Fq3MT}Une?{8ekZA*$7cO#1hN_N8;}gh4SD}~ijiWXd_czr*!QGff$H*ryUhId z0tzH&n^_nrOnVMJ_vSp8SKtm_|3g|F_aiuP1eSi3A=pAb=JeVND9ZJA0UIo0u{-=B z9neJ=e`|}(L^qOKK)+zu5tp{?S>;tZw<l10MNCYtf+Zv>=~&va@i8(cy_cAaOM<=# zg(Q;2e6h*#&>N@Gp?9-bwN-oSfD@V1s<dd*h*5x_KPM+gxYzcN)ezTUwRpkBkYuSC zab&r{z)RtKBs{9k)cN#|fZ$N<FKL!bT%4e3q}=q0n1;<}rvzz?uWXf+kifB?*!a&K zKRqZ*F+*da0n(b4lU=%0XMB|QjE-)Ef6Mu{85s~`q?%Q#J-JVP+pj;d0eBR&%Sm3m zo)QhtF{Sn)Q86jZx|^2(NO4#lEiby?CNi04h@fF%fw~p7_Y@#G;zmj&aTr&#=~UmG z?~rQ(Vx*=!!}atrN`<XO!wMU3gy3-x*Ep<>jt-#dOZ67)Bw1WJ0CXtmwNYTIATY7I zc5}YHP#TswcJ@cq<8dYTx*~S6NSy^t5&CM61z<$^hmXJh(mNZ^rkk*h&FU!pPWMOX z&26fh&T2Y}g}Q$Im`S{Gb*EkpQ2t}yo<7d*Q&i*HlNnNjCd)|C*#LK2GhfRkdwX$T zXJc=`eKl6oI3m1}$@FpbM)Po@GLfP&KR*1^VGkJ@Nu=yNi@|Lu4sP%xm}j%C%ypNQ z)ZTx>MPZFPwk;NHOerLVg^!hZ)eX6-fhfl%SJNNMR+<S|HykahE9a5U0aZet`umvj zKQ%6*LV4N_4wXdL5nWjzno?_)%eaQ7*l_I7wHw)?1_PDC#M_d=o}PQ%8G3nGC=|V~ zx1V#E`p9|~i?D--x%2l01?QR^6SaBj9=p}@I`?T>QIXjUuCs8{24?=gbI<m?V1uMg zs4mE<=ZfztoTS9YvUvQ8Ejd^ZBPMdEU$G5iC5nzbq@cMpF9c}s(n3A@G*!((q6T`7 zZ}NUhXHslOe;h6iLlfGc^VItF8}J3OtJwjpbel{%Y`xmSibuu9c9Y&3Ea6u}M9v*b zpo$3UPl6=eTxVI%pHdE_7Akl?Y$zVO(H^vIlN<|Mo!*Xa^v*;T$@guRu<B;wP(9N} zq<Byx8u6bn8SA{<pW*$M3Ib}+6cVmj)DVPlH`XusQE5|2IN)MmN7rrI*9vI&s&M}$ zC~26NAM5oo)8O73yVr~KP&pFKsnpIid$bgdb!YT|^4&<1OSsTJew;4e<)<$L$D`7g zWPDA*i_Q{GR}v4pZN1k_VH^O<k>1(@IxJ9Q{@nNttKQ^XC9qAeZK7BnunzGF2&iK2 zkh!nVv~)6<x60Q@Iwj*s=S~;qodLn7#J=-)lF9SyzKlQbN$N>Vp^IGb{(7qy9O}>p zSo#EOPTz06c&R^^a^H%s;jupeAkB65WAi&BB(upgJe=}ZaIeG@=|o#9_ImpJDb2GG ziqvbrvhKFjD=tHC0r_{3z+QG=1p8v8^(4>%J2}33E!-6fbW8X(3@S6l6+Aq=nR(Am zCM+7SJHx|U+0_mK7n!K_{`0BjOog}xqz@Gp)z{C@zO`<*;)7>H=^f<R{pH?bqG5T{ z!=x)%<HzOY6;Wu%2-w3k{Zs<Rb2-s&RS0ej`Uy?I{+qr#-nz<c#;yOaY3q%_$pNP; zpkptDhlkJ4tC_!h&EC0am3)$2N-Q}xnwmdm*wd%G<C*HJU~1<)lkGD$I`#+}85bqr ztP{nnxCr>8T{2oO7HKXmmhcn)!)^nd(A{sRita@<-h0fLF+1b2uJ#%;7!)#`^LSc= zwx^iVFV^(Vy;bo`1NY?;#R+8j#yx{3rYB!Za_M>%7Hqf74?SoPeg{UDHg8WG+e9%i z;K)ydK$uxtlGb{=V=|Lb-@_>RzIG{=I#LmUql3m>By*X8gx3JaK*&XUFM3Tadn*oG zt_x0JXLL_2od#ylw=uR=Voy&juWoirk}ub|aT7@&t6-6LxNR>k1w)BB?+>>?!NM4c zu@9+#Xb3Q%cwv!2h7raq7Mjhz6fV%IRG~=GFec1$T5rEazdPwW;Z_voSPv@s)zP6+ zYXUBjVt$O`b9<!QK~`z&g5z@9@3#;QAR=md)?Ek)mK)^;Gc~!XZgLr^oUWH3(D(G| z(f9UuP~#L;6WB%=5wo*zh3WUnj*YI*8H?{TqT3&Qe5<8Wxmn72d$GB`j)~ykV86p? zK5BH7=AC`{Ow$@oG=+J&;6M&CLQwh|8CkPjM<3{5h!kYDtMlpcJ+^?Z=D(XQ5%g|z z1G>3Dg~elyxa(`x43rvv9v#~iRg4?Icb$vtX$R=c0O5cp1k>2l{7|5Oy3nA(SmvpD z`B1+zUSfZlT<gj0nDWw7Wt~J;wZ@FjteXhPY+Gv@4*2@2TH@ZW-KB=%ibIA6nx~v+ zX4x&8okMMjAQ<&{QSgwE`jZpw6Uf=lRS(L<YT-nQylnkN<sAC<T%#gL?HRbU0R5^= zmGKYQr~ssmnU=K3xbEPCfnWaM&yaHVqRp9@ghlciXt*vOqmYQ0Oet`^m%(OrVPqwe z*U53Q-|0-*e&z|$AR@vE{_#&(P!PGhL~(F7XdVS*>T0fKQ9$|9W8lDNm=b}6;sl2V zQ9Bgi3c(O+GLW#45VHw5h?3d%u80Z?NB2|E(|?N&eyL<^Yzz)9aB#6TEEScN`jO9H zyjWY+5goeKyX;lynGJG~D>W}G$~x0k6tHn4I*alyE29k)eso>i=M6v-O<*+`7YY-u zu-w3N)ACviMs4&1SM#OTcW19C1?G*{Qa+%PeGxRDuG$_OYKijhqrqac;?XqE@=XWK z?a^7|MVHeMoY`7g=(|d{soFALe+GnM)kbD>lL@geHjLYic$@e2F7_ZdM1|HDn#@iW z;}$;)#%_{Il@z=w%?1z?yq3~o(WDxHC-BVD5R{pGD-_u?-Dns_;jg**XRrqPI#tHc zFTKbC=`@O8K)_(MeC<S2i$`l8WP2vXj_gbCz-xw~TCaIJAlm~TQf$qyl&YZ73q1bM zw$c0-%Zx(@;D2w=5Ojp8Kd=GvY~p|c=K!_mwE2#kA!E0vz?iYXmX%i|8y*g;jb2|& zv0!NLBd)jzn*$};cDFQ!5U|Hl*a|&5DoeosoG$R~`(0R*$wbjagMFb&wF#Nr&vuJi z3Ya4j)_P|sN@R;kO-<d>t41Tgi_B7LmjGPc<Tb(|wc3MHO*Cp?TXPJ&7$&X@<qH*F zz@A}!N%IUq!XXTn+xq_BiM2@3bh{Uqae6cn&5(RMIcfL1v5yNM5nutHyz|Lyh+mLi z>F3Yj4Bi-@DH_p2fal^*HuZ@6Z|%KjR8xEOEsA;o6%`9bx&=hKf*?o<h=3FU=^d3W zO+#-1j|C~x1JY4II!KdF0F^3TKspMc*H8n2ycNzl_dmuP?|r!6?uWa-IMnQ&oxOi$ ztvTnK>-Ne_GCjXZHM^LYK0dO%yu8+D?-T>FC9M7>x)x{wY1%_xqAMOqzaKwT@@mmj zg3~FQEy3TkPZHU6*YeXk^O<!wu4zi&yLa#Y{kJ`mgF9eJU9+{j;I+N1d64%o^^Ka; z+Kxrc>Aocd@_)wh?r&D(*mfD{ULqqSGo@p3IcA+Mh(lal95Mbe;iitcd0LXK8J}V8 z9|RG<GTm-SqYB+ZMupT6gW#B+j)1wK{vA>dGO`WnhM3^s;D&~y3}=oXk6M-c$n&~* zrk3;AvG#N4&Up?;#*5fT&pyQmB4`79bF4x$l2Jv_&_jvT)i)*Z+#~PuJpfbx=_v>- zfmQ`LRaRFUxu^>J_KIy++Fic<0W`u&l<ZFK>v?74o(n9mg*+C!6<AR_GW%Kxb`q6% zC>d9OZ6{)=q@*MxQ%fRBsi~=`G*^=r7Z-DLdE?s|FItDZdzUvvIc8yQKIS%ljEbI5 z<9hs&{XO5+dN05Sl}M!~q#GjL1iGQ20dRLdlYpS0)B+yUu(#^2$j6Ug+<R37R$oW8 zw13rjb+(s{R9bbKye{r8f1c`y*{^g+ypx>`YgKkDFNu30n?Uf=wY6N}WUmp7fB9!} zQnA4MoAs^MWppBM)gAnl;^0zt&wiovU{~9la1->)#S&mWTF+?haKdYX!><!NHI<ge zK8%)RXTQbIuUa3&&Kw2j4q2A9IK(6taCa-Pg|bO{sz62X-HNnNpIXll%n2DM9jxyj zuUqMQbr$Algg!4_z(aFz=ec`bD_SKWyh!kL>)8uThGL=DjEdY_-ZM3x7uHHh3p4e> z%V7?Dv}r$3|F6Xk-O|0%*AgjG#mCDlAt<Qv0SOP!+{%jKbYuCByXm%nsa5BgvcHM^ zD34=6aPZFK<?RP?w{)~$NRdVc8QH<z1$Cnyo?@}L8`(<D4%8=4M$2BJY{<~o6BHC| z8gmw!L({kL*WjZK!2p8ha5#~X)q=UnlVW{%%w}e0wAuGnRDxS*CT4pjVTQ=d$kZpg zjYD;Y6nViAxW7tnj|s?p#(Dj-Hj^dahdxW!o84}i1O-jTzSO?I&})3=6>|s_4P%dW z9xPOhM-x6x91HR_CJ@$%+Ne-S0pOVM@i<dKVeu9{`>nauidHCA<z`_aQWWXrG*x%K zz~GDbcj(xXIw5pHD*n2-KK?f99_cI)qW@kj=j4@;xN$>WhVqAHG4mOh-7fcTFtz+J zVde<G3+MRnjR^p0RcJu<PF}NI=;jO6=HE9MgOS!#WLyGxn*#!@D?B^D0uj3W2QGy; zQr!gUaD+q>fMLP2V3R(eTY1I*s>GGM6eY)|XdSD6uL!^7ZdWd-&L&(uB>Kqe;$fS% z7s+X%Im^hHogxG?d+5Rm0N}M=x5uvUgNHjU_jg#vEc|(VNwtx1tTI-OCM~JRg9$o~ zGZ(zyG3<?&dJWn~r3S=p1SbE27T&~XQy4@v0*BKAa;9sXoK2Qx<P>qB34n7%MfJ{v zF@HL_^=h;I_BgAGidN=5C=E=wY4!=H{O`{w06D*AM@Iv#vx>O%P(61UFi+RDD!(g7 zU&q}0_eRcCjhcidJE~J;o6aRLUlLP6er_ENyn#UYuKnRSiFRm-80Y@Awa!eNLb`9V z)i>4E5>-Xe!b!K|D<xQ)o?(;XP1dgQ+ywl~WlSG(oLv3l2lBmb0+^I)C0B!#!GvVT zb5NWxzsP4;$t%=LHCmS}Tj^S3J@qyxPh??IO(HK_RbGA?&}-nGG6|<Z^-Vg)J_swW zyKLr^Y-~KAlv*B3)qVNqL8Z%z%k#3Nck#E_B&Leil2xyRzGQ~fKh|fn4@S!RJKPbz zGHy4X21w^M(SHgR*v3kpj~1o|XeT`saqQoSHgu@8S$vnB-EO*Cj3X_kq+{i?Xm1#< zZ<Z^S);E9A^?4}0si((y?f&xVED?v19T>1R%asd}k&(2m(&8%7I7&U`s1-%)&52{% z19_SX8r>gc{jZC*Cu5}XBqI(u{OI*~>EJp2^;ewAH7pYqJwGF?76zV`^^eL4xX0sQ zev7lf5UVFL228zNRykstPbtUnu?bEU@fm)(o)FM-xz4f-J2R70Tw)6ljn@|Ucw`s% zl|Mg^9g#N`*Qi%?tz|xXD6}Dcn@NiBICM(Ustu2h-fB(_RUSIe$au#Ccbo0q-e*Tl z?P<na*1exL-``B-R3&IV$%S4Nvi{WLuEU8DpSThNTtA;YdGe>e0aR&b(+s?;)ET2@ zmbktG#*M1?Ge1md+4xH%{#_zWV1!~vtvC8Crims#``|4mxO%X+vN1oHI(zE8ndJHp z&DK~q@BQ|&lU5)5(8Ysx^Sp_o&+p=szG<H~XsLT}d;E*j4FBLz_&~7wv+M-f6rTU) zX&3S=TPYhaSa}Lrg`B$YENs3y5n)+{`r#t{RFr#;fLj+}jXW@_m=9NAt8$)QwQM)s zTI`!y_FCSDrB{xJ&1ZkJ?5aYD=j?9<3wMCuiTf*5vivO9f}lOv&KhTu1ZvT}Fqvn3 zzbP5T==Aj$zcIf<PswXu+|S<5gBy+}io+f|tLOBcFbButMQjJZ82dl|TCjucNrS#w z+7+!Vk5~{U40A1|R|~==oL7K#fRfbR;8;ORtrL02p|S=OFQjQAS?Lyu!1@eDIhNTO zB)dN%xn%FoUZsW**B!v`ujE4{c7o#N??$FZ!p7u?546}DtI(!k=i+I@Och-7;kPZ{ z%NGw!<R<ErhGsrYt;Bt475l65d<yABlV#pWrqFAP;p=Q}j(Slq%g<%;1zx7UPzI@C zh}}UIhZzcWpe#^>n|sx1WCku8%Y((+RV&j6m9qnC2$q#32WC<CN8lU>yG1lZeubsh z)2G=YsGQzvHWXnxUXcZw4DF0$BzZR0bPP~rC`Q&Y3O}79ns`7SUDa7_*3{8fP_D6e z=L*CtVqzXkLoTFB)TJ`hz5LUb4-H(z^5#eM#ZZ6Bagu!bJs)!zgsc#IKQy5?lHGXz z(wlej64OxvJyp7D3_E|?6p5Z&b4{#1zl5{w<3(_lxYjmGW5J)2SHOw0kM0B&b^NV4 zr|mmFYin!tZZ(+-q07_~JJ03}--EbgvFO7)Mq$G2<eFSY2!oJ;)DvhA(G=q0>!#gt zxr-Jmxk-<!TAJ#!_s>_Wo!U7iFQ6S!w3HR}L#cG0^R6eSFK<+d71UNP0Eg+_ddijk zFH-|-(;XhOA1HxZJ<zLMtp9U25FDy+nmL4N*KA>Q>G%j9TZ^%7qaIcDj{sfu-JSFG z(9-u@$a4euSDJVLbn3hS=7%p9O~1Fbz_YFvE4Z{=wc?bl-%!frSgqKTLvfl>0{?BY zh{F_hbM1?v<B8+P^<a~ORWP0VAwK@>ZtZU9U)rz+sE5oxjjo1fMUJDD81qL_#k#o> z!3>g0Jv;%IlHGB!%aXez&u1istUA67FWDQEv2NFUedot@7&>mvjy-r%G`vz?rO;Wc zCb_?eqxEgwVRF29wMh>B6gS0%GXd3%YT_MFm584jIozTiI4$fkP7<jBhqRbAt%tYP zG3AAQr4sA0MZ0LHG$B*fUG7`AoG0oICZ!0{cRB0{ujx%LUbwLC3vNK^ZPB!JbV{hH zS{O@D;qiV_#f$LrsxUb{jOjRcF1!G<Hr)utjf&67>6~T`RvY6TTk2-J2Ws(+3q#HQ zc?R!l)*6YmtACn6C<9wEEOxNy-fvWnK(6zZaj@Qr3Jbg8)c7+_*!Jl<HDJ#V;`^y3 z39}_Oz2YXne!pSL*G1n|W@_tw)wRuXgXA?jb{n`Z&ygf^6?*tU+&SHSAVzgH@(Pa} z+Z8a_Sa=(rYN6YfZ1}|?QN*Vaup(C9#c-bdi>R68G9N+I-%VF=)RV92O;PLcViC6; z2L%rxRgM=bwhCP)dSSmqf|@)2xiIaW(b`PwKINHPnWn_RHva<s;(i|uBFYD<-^|4` zI^U;zPl)>bGZ59!xSICY-FZH8?onTE>{%8TA04XJEnb72^=0E>1mpGxo|>LhjH1$F zhO72NC0Vn76Fm;ZbUo86<~}i%z5s%tQeHJ-rvmvC+<9@z0-m|Q1De>tOUSn=gg#%d z_AXPg=O$wwV>cyR^^8)%t*Z(qLf$(Tf}gFspJ?lslyv7~R7W_}n4fQ=1#f&EM5zne zZZWqEL1avMX4bUl@1_tI%;r{A$wn&{t83E7t!?Z%7xpoTu)FXCZX#T*0^2VRrkdtL z6nBW>QBPW<Y;e%m=c`1cvdOd&K4P-Tr>8$ub>wi)U8fFpZ?3{ovDD;wu`AKiuv|zz z&CVh|eN!<aNW^}~JvcZZARr_#;su};wq`v-f;vDwAKTyEJbm&c?ma?O)V(LIa|VT& z*=1_9Hbd!`tBZLtuNb9$kDdM1eG_T++$IhK?tjUDX-CMmCkPJ<YcymbqWLvLbY5Lm zHh6}|(Vmy^2#(q$EH3`U7!Qlc(c^zNB?pE?x!=!u2zZ#Gb~cx0M$VPI<3tC9XdmYi z<^F{S#}9Aa<k1aAbl)}a?m%d2rz(~$wFDANxzvwE&24SNB{u7(3j=Hjl!R<V#*g4{ z-@bCR8^M<O1i=9@HJ@ZnmqF&f$42u*`xJ;OvrnC2mB>s`(;{p;b<!tRK6#z`QAa}~ z!tU|r{FtwU@k4`2nXiMoaXC79aXA+}O)p9gb$9o{RAm*b@fU_R35%mSGhy{H_am-T zew_Uq#wwrd>mm1mOB^;z63$S_-=8<Eq-Q?tcVxfw;-jT;jPLhk;M+XIy?7c>K6_wR z(9FNP)`V}pJ$3dvf>a#~$Li74ZtGHRHgY`xn0TxvikxQhzkKgY<$7&y0x|k{@27SM zsCZo|Lg}ose@J?yC!pjXPs*Qe6ZFMFz4xP@v;w7Ad+_r^GeCdaJ}ZESp-)$iw`>Df zK&E*LxGlgQ*z2PaRZNj!9;9pH@*SEU={rLwuWg#Dj?pSLd;4{|cFCi>x@?B5TZ_+g zvr#pkmoHzgsHn&`2W4$I*#fT3pEO%D-gOUAci@-30v@0pwf?QyOkrl<;JW_I8OsV~ zpw)Q*R#LN#K-#WPt`~91$Yrw`dcY~G2<=2DgT~3Q*z&Hq79>qk^^c&-R9d!QX7h1- zX~A$X0Z4v+0KSCw6UCj4^oiWhBceGH?G-k$I?Jf-9PRDLp2)sF-MDs3`*OE}r0*S0 zqnWlrr$0|k(24uz^-h8(fRVXI`_$CbA06y$n#whFk_nfU`sPqh!OuL+46`OG=mW}0 zJn%o&orDmT!w0KTzmWPbicc8{E#V1*+*h&V%8RyS9$gcs_O}|-2{)bk$7;NCw1Y7C zPw`Wo2wei#9Qsr>zR`BTWG}RQey32g#x|yEJ0G@V>toQ$5o0&oCHP|{Q<s*7CC?>H z3(Kv3tj(YHn|!jAO4;ah-R>-1ljry@Wq;@8kt~2B0FzjMKLkPclYuPqln6^`{+iQ# zB@Yw^Mk81Frh;1a;{L+|d|(*NtH79^Y2TZh&nbwqk0i@69hW*Ewv2f82!<KDb7$l1 z?1g`7$jPefGLA&QlB)6Cj(B0yYf007$p{`XvH5Cf&3cE|oDIX!d!P-jt@P#SV&=1X zBrQ!`cYpiI#h;CS=<Qui=akEPIs3hc{VrtG#Qo6pp=4u$W$qOKK`GBLtKU&yX4LER z(b>;<J}G}*mCMRg*sL-4V}YS#l@Je)q2)!%`Js<NZp%Lle@Wyk(>L9L#ucA@EZmF* z<~5uT{QUgRu<(byIL*}dGvlC4lR2@}w)4u*A!=0@*X3pLzWo9Ux_@&xsuJcjYv~C- zB5I2vfYyA!Z2b95VVaqo)&0(9_xV^WDk>`3yLTHyYGjxgXle88(F!V&@$q|`1Ex7A zt9Ive(8X12YK^}0H*NYC#Yo!|l|c1upcci(3LERPd)UV`JG!9nW(SbUu-<sK+8mzh zT}CY#RYeiK;(2!9NmfEaLPRA3G~Mygt5WdUQt1Fq;t(`6^(8LYgoekQu5nH)?oZlk zzE6F7%zYmwy`+5Y>iEpUKn~}zoIu00%<n-l>>MmPh8e15K~*N6b7x(4=U&xL`X`oO z_%9S%*v$sF61+Sx7Hr-VDD0xkUeWIuwIqo?^brnD&d&Yc{-}Kl!rC4iZYHm63>=VC zAIuMzm5F@48Y%_T><mNnE8EF8@2(cG7~6d<EOr@4EVErYV(PtSp_sC@GG4&U!p9Qc z?zKB-WnOMSECQi$uVn~;6V3$A^I!9JoUklzBQ<vTlS@4JS+~bw%kOC`up6x`1)rMQ zS#pdXq^JtX7W5|WA8_x&ud*oisv-EoPUWO|eCmA9u>#gRE=zhbuxlVTCXT5|;@zFB z5Km5=5O%13{G*(DJmxoWhpnvqm0utUP`ya(-ZYc9JuLx*!P=sb+NjR}iy$b+Harjq zX|2*9>uh?hn(IQ>Ye9YljS>6c_eBZdm(R}9{kvmVOC!&wc5A7Jt;`3HEU@})JXZ8I z@mX?Fe9P*yxYm^HUIc<surXPi%<wFR{Assyy>rxk@^ro&EIw{nbWCT`{wTyKSTwL& z;o+Bm-v<LmdQ%nXuN;*v&8F9??=$z3daTA3dWiPt`mCkLU}@>;XXa_D6(buV%&PM_ zfEA%;Z8wKBngpomTa`sb*+oV9B#q-eH??)8R_lDSHJTqsytKG=vF+R=Vf&#pm?I{> zXFvR<abB5A;hu1D)F7RJ$rUfXt@X@@1E2*R@rn56XVx@!=}2^SiATv=k-tJ{g^aD& z?C)JB$2h6YVd^p80f(}Tw6t<M{6fJdgxhbqi($8vd`7GpS~a%{x;q}8DBv%RXZSC` zmi==PYd4kdJq;0bvLMzl55XJo2$UjE4~+{3f+~OPqe8qq(-V&L`m<FNcg#Zjv$5`( zMz9s8=|WU*-T!0GYV+1tjb;U3{P%B$jum288rH&O_-Z09S#HeQ&g|dNEusj;LtB99 z)aPtpY$j(NMxGQ}&uN1<DB{3+q-v#hDRr^n`qB+@a>^X!+;B!HP6SxDq_{Z9dA_Ir zuq-v0@!qaEWhCYOe$jhdk^D9J)q<Mk&D&$G9UZQ64>82TjqDr+x}`PatB<WQ2Qfe5 zgcXkR+J{5!h+>Mov71k~Wm5XDN|*Tl$I-kTFS2my^)Ce;9yF;hcg&MbFry<Ez6z1V zZob!I?>~L2q^|x9S`^_5L1DACIK;Q|;qhbW#ADNww#JZ}Yz7?bHI_V6Ol{7K4{nO9 z&v#ZIpZLsp+W|T=`!wQVGI~s{p40Tpl6gr1I8rte+WhG8dfdEOSdVS}Oh>GA|8d$3 zEp;F1xoG|I)QI=Ol7Txr@gk#$9mvEhz)Mx}TOfZA#upRYo6#)YJDsPNAo?LMO~3b( zPP{f9SNah%u4$rZne9N(^bh>OCqoC#+FRRugD`Wnr05tmI-d0^0EBrME1U@+JJlNI zSomvcE3l-ECkWWT=7n6ktZ;Q%lU6GrrI<v~&kx)=wP~lwT3{HaYsc~UFH2S`$jig7 z%WYgVXlFIt(EX%PE6uQEd6YkTLy1C)Fl&ZY2013`*OBN$VhQwAhU_ILZ^bXQqIB#F z)bamZOnW(@+fHPAuGg?W3#_XPYIloH@#^D-=lg7<1U8q3n+E1S|F2C|KBqepL}@8z z`cT=<%53yO<jG3`29T4Rg-TUBw6|jV^X+;oT>xZSC;B|ZzaL+A{THPf01FSs(~Sgg zD)eV(<ueZe!Ioi|dX#nYqKIAQR+qHoeOK;gf$Nim*W{46(=IYp%fFk4yb~x|(<;|d zXb+cbijl!<z>;cF#)G-{Ki(=kS~@y9mX`A1=nP^XnvI#AwMiY@$M^1;yStZXzZY|= z#mtQDMX>=R7#I=&w(uned9B-0dv=7ddNib8(6U(*>U-gscmM-IJ~iv}=S#P5>&jS7 zrQG-Apv?R;hc5pU_!~N>=%9G{Bz=en`{+m$m-(NCJv}@uq*_!s+#~BBy?y&O>;>o^ z$P>HIC;_{Gi-2B}Djzf?@KYCB4{c=NgVnsZZv8Yr`FSvRV>+J)DLRz`)k$w&y}B*{ z%9TH35+PmN=A3Q$I;i0Pa9a>P;*!v@`g(eqDaVkP1Z#}<mDX2$B~vHNI`<d!vE%<D zMq~6K6gYqWY4LDGWTd@F{9SZ}?1cNDq@p&NaM{%AY?#N}V_s!{cQ!AB1oAb`5h||z z#(#4`eH|HE{%$0}TlrCnn3R;shrs67L~s*^o!Hy$68j}4uXVm4x`XTq`<lItgXtWB zca5$2FVe~~&YpjCWL5kSRzl13i-AR@L$UtLm$IM&qRGCiq!jo)!b*@_3nZ<NZd@OS z{r#fIA`U{->+Wi)@~3?hwq?!DUu8RcQhok!D$oWfLvOZV3NsTE2k1t2H@&U7Ic!7e z%nRn@<LSCrxCL>=Cz5p(e<EuG-ySF8f;tD&8UGz&)Z~Btbx(Z3#KOYDQdIn$LV7q$ zQevWo`kVK^NP2MZT$|o&ur4O4nF$gvSFJ^cHVQ-XTTgQ5gJZ%FIHHFSy9F;j27cwF z@u1ex!Iq*N3;(uf=2}sMb~@-Mb$$nGh|O1bU$;0c_LhY<*lfHY2MU4`co1pyXFcAm z2otU<fTro`bx+X#O^EJ$3WjlC01&4zb5;o+U=w$jU#I7FU#<5S5-O(YFu8Yc=hca1 z@Uu16<^uWBe-w{4n1gcY6%OW}&;9nTQOBV45(c_13&?zEcMxKHaGr+ZufH<0KLb_? z5d6)xuFL;$Go75M{xn51?LLyz73ue6u&ZQ&7xbeP{XgiasVQg*8{htEf}=>tBBnJ2 zJ<21l@9LtRuG>1u)~|-x@5-^Fel57rT{B$GHa&m2rswiMPgNRwlkpv(LcmbDT6DIw zNIfUG>(HHUua<UAT~rxNFEX>(ZdHvo&db)avs=79?(OpR^Z-v(y|LTqO+ZB@oIbMy z_dMZt_<mC;fquJsHTCmn={FBP`GJCb;*pQA{vBN?IkdF2G{}41`ml*leajr{=BZt2 z(WYCCYbEyQ?J0i{CBX}G8Oie??n>u`=T4@5I6b5hNF<Wo8y;3GgBiJa_iY{hshOD> zF`I65w{_s#x5rPP*7g+kt^a$##LH}Ni{)fSsr~SD7ANb<a6v&22|@ms@~*rRlRkKb zr_B=4#};jV(;y<fEbgA?qe4C4N3h1q`>%k`gn|_+Dxr&~l*a8uh9Rn2g{>Sqvzp?| z0N0Y(noZR;x@lCVK&pt#J|rys&a`~Dfvz4xariyE2OndliwDgG88~{8sl>ETf6V>$ zh>AVQe-pfQe{cd020<_TocN4ub2Xc90<rgRISfBo7Hb}`gW)RV1hN8E&dn3`N~GET zFNHT7%hS?YrneZ#qoYP)af6aPF|`6ssfdN5m%ZYz0wi*DrU1mSXBb5vO{B<xC0@i| zI*C;ObAf~lQ%LP6xeX0><J+%&{esa)vxTL!J|e`|<8`H_{qE_`Sdf=MlLctz&B>~y zq5|pg8UlCiypW(^U&U2H!H=qml`d-g$&<zhwp}SQ?(XmJ41kE{UqYA6uLp=iLDGf| zhji1w%hdlrpN?@dkt#V%pR48k=^wzlLO7(H$;hJa!r!4Jjd1FpBqEAbrkl-*KP;3z z{LjNtrXr8pC4E=Qy*t&`f4I;1jo*w_A(7u?>PJ~jZb53es-V?CTCe?{%&j8>ltsx7 z-YWegdEoUi;XnMqKPb66FAR<@s+Th46ss3?NSD~5tWfWnaT<oI*`2I9>3`<O4&|uH zb<%03({<8^xBjeWor7fZM5I0YNDwD_mvm~_t7hvcW%hJkqlzP|w)HZWFQp9K4{gCL z!rQJ?h-#1w!Rp4z=k}~fhCF264~Q{xq@SPI{U}A~kmf+~G&5WO^!ZA|+CWaGWILB= zs-8mnXkGIA_7~S8QB}3bo4=M^!K}X3vKXt6JA5)bM_v|VCn|7{c;Ie#;tA{qym%4R zMG^v&Fc3^3tFGQ9wsDX2pQ~C>7G|oRNxI~OH_gS1XD>brap5MBXy4leu3zUZIed+x zruQA7JmL-|{AB5XDf*uIN5w;ShflcU!L`(G^9bEKv7eS*$KU}(1(@qan6)^5nj|J1 z)|btO*0*HVC_A`>IQN>|`RDnOk<AX_&o&%lLSR93NOF_z;eX#G>i<Mf*#8l;kSwec z0!CY`C1`gc=m&mz*iIz0Wa2>NKP+HyexP85QR>+QnPzOO)+fPD?>KJ69&n>FsuV9} z=9oC>|NZw7?urhQ2wcg$)W8XrKPM-s7Xgi9YHDifng}O!YT?wNOG{60@iRIG;(jPO zfY1xLv2@NTFbIrs`Z&cc{&FPtA(@|V#JD^@FJ_N6pG~pi)Z2gn@GJA>IHK<A>I&w1 zq-rJkKOdk$L^?I;OzJxV6&<7`o<<hMbm77Utg(Y;b3rw1jUZoAoV&DL!oeLF8tPWs zor8qW`$fzaPu($uQLJ7KId@7*ic{GeK5&F?PF%XcsR}^>-=!38@!6EAqhwLxs!I9A z4VuS~Owd$tg-1mE*<DX?U^_#WsRHN(9*>7cnsh;O+E<kL$)xdG+AZ4W;JOoGXtWDq zXAQjIoM!2n$x<%BM)mvmZ=IaYUxj9agM--`>c}Hxt$9jH`6o#suxS1fh>R>Qfy@Ba z1QWzc0PtgBVQp<Co4YOKWSQlyycOKb2{sGI$)v8_&M)1fKXKv&gas$b{E$HX--=b? zR7=`$5toA7@YB^YXu;BwCaJN3LENYV)F)zL@~X=~45<;fQz>gi`c#>gDtrnSCo%=u zQ$wauta_RBEPelh3Eh*wKWXXg41N1n9cCnzvk9{M)mgvfh@CNrmtYyFvn+<Tfeh~& zL3ie%J0=Y`xyoW1Wi*!FWPj98DJ(kL6|$O&uPFdHiimAepEwa7L<&H$9HcK9Uz2OW z8k6oYyzgl{Xj4Jp!0E~J7g<6O#8^@5C$Ji`nI}`>`R~NJDf1VA3TGk|hSQR(7evld zJWWT*I*wh7<N}5vkcrD#>-9r;n%?bI95DkA4`3yQ(s~>#149;kIYVB`TC0y8>Gg1| zK54<a{FJ7caGQ$03%g(aqvJrB!ulCJXqT^BVDjC|Bm=%v>x?7Xs1~|-)~z(}zXDF1 z75vdN|1#lnxL!euN6WsylUbgDr$9QS3;rQZcEb82-?o^8_hJIohCY(y$pY&Y4^$X* zeUOEh178D=(GW>mk;P%|!03U+X}s6-eAdKd!r_G*c{pMOV3S%EN1hH#N&++ERmlP) z3erg7e&=wEcNd<5o1IAfQL>ct+Mj1XkZx6wg--H+3eHJxKyVgmW{`uuvurX4{o$@O zAqyfN7%NWxQp)`QnpHxv(3B)72#%0F<0p-Dzki}lGO|kf3#6@~&L6;jn$stakj<R& zi8dm6AdxQZ4AZzy>@HjxIQpGL5Xg9_-M_D17YmV6zJ8S|#T~c#`zu0Z*(W)4v6I_u zWE<et;5S~V$9wX6{8#_bQ%tDzQ+9foPk{O=jgR@Pyae*ZxvG3Y_-a0X8v#5OLlO9r zQzP#C?*X<`p9umWM{0c$bL{9b6OXyqP|Ah22nX?DIpp=i*}A^-Elsp3h$tiU3iIwh zVwjDH<2xE<4gjU82eyw&++>H~MC6{lLQ{2}4cte`QmAf^dK)_1Z4NJ&E29TS*hrn_ zV?YfI`JGOV_H@j^Sk2x~4|Bq+&S~%M{a}XTYd)I~80htjT@x4mfyv||%QQk0CrxYR z{2~Z@6*Im>vpMmVYN+PR7UaKM7=*iA2j4Q^GOFBSWG?-LzGA6B{Rp4otpN&y1nAJ@ z^QQ?DkvIN%ZO~VevmiKFg<POJ(71STB=eyx`Jp2#nYLqVR960Po8Al_tvVo@@fP)= z%5(zEfaj6VN=HQ=4V-{$fHU*L*XL|)JmTVp^~z%&d+j!wwfnY9*@7a&O=2?tzIkn4 z0RhLYgcb8TYv-u8an+=3^_z*}Zj~uM;^eckC6PxD0wAPgQ@PfgB6IBNKz{l&)^4j7 z*jwvXem>Lib09z=VsP+}+00B#f1cpQQ&p}@CS}V4<YXBSk%=)oYm|P)V6j-Y@|QT5 z^zB*~k;@EHgd!-z*o4#}aZZW=se0p>v+b}eeSdau3UHAm6%e%0(3mb8v@5pqbsPN5 z6DQ(mjFE|ydFhWLv}8c7{JfpS){f>^0nF18h2-YdgZ;gg%Dr0fsfDTx(=j&BsX|b& zp=+Dd3Ti8UH^xK#@>tO(f?FQy`@9bj0?sSl3L?1<`||W|D;%axshao-#E-~LqvzPS z5tY0Z`2JywH`|Dd8T%E`LmRJYA}1cDDB(OCz?nSf{qXi6?hK1S<>r<+^dtJ91g*38 zyPGEWPM6;kG5PY@Fm#OQ^Bdq8GuGl3i;S%gFpIsm7WW4*!~)$FRNvE|Nuq01<XRUw z20U5@5C>SpG(DW*K4=ThFrh3aKk;F$2tyCeOg!<v3Zd5$`;^YG+#wKrO{3Tdo~Iex zl=7LJCKnv5wKx2C`v%6s#x?Nie3ca7nZDn105n{vBFGI)P-B!PNSj2@(^!RrPw|d> z)59&xLe)6TfO-AxaZiQ4bwG4_b+Fx$HGNO_m-i~J%7;WJ>ZDqZ_S*}T|H-zs{PA8} zPj7ZDU>UK8<1>Gmo)%VcZFm)%R$yGr2k?sa)qMNz>^z!!92$|h8p|DD%;&nkzFxY7 zH=8T^@gw*4n782@3l!Jfh}F7Wp}0?@3SQIIrdzD-;bJT10++1C*QW#a*NJ21z9^Bk zb<j<O4qkWN*Mu;GOnc^6;p9-j&heSzAK3kEM07`j@E$hiz*Zzar~|Rml-y{9fC}ho zKEo+cG(`gUke?PBR(xmalwR@EzNa2OdrMP;#T_3pXiR?rfG@_A*WN+JanQYo$d9@2 zVv6NG+TY)F`rWxMdFO!}-xZHoBfK?iq*slEpjzLhIsNNMXDEkP#J~)=7ysTHgw#!j z$)lcp$s)PAjzCT&Zz9xS_H}AOS%UM}_PASt$hE6iW#bV70-Zh`uu5$#-M$Wc@}5(2 zB=27R-b&_q0AUQvZPqOZ-h(f6i*kMt-F{JuYT{7-8uCG=PbRZ^MM-35PveX}?@5$e zK9pIAy*a9y^NNj#<kiS6z{C<@WHhgQunoSat3judB<M1|k<}i5o2N}_s?29KtPP0K z5(GAeINOa!9Nh9HWF-4+V!!1Z3n1IMFoi`tf6E4{*_E0OJxrCa42}BnrzIoVW%T`E zscl*H^!>BKZet3QW1#^7&-40A-!6D~l#L)G_p({NXV2kl^AQ}e5izgS*HODRe_pLY zxvg-^X0gEc*3H`^yc}^NCU<2$;72Wwx{Y}*6A#wM_Sh=v!dG|-VD4cEJyi259bbN- z<F$vjf@{{^crDkM-o0+`?Vy@(%gRPu1=WnLgcB!{O7Fp_P^uQTUQN&E`_a*%s;U~j zFJ{%TU2LfeVkPHx#li*KUET^(0+y+S9M@32gDojg*mh|3;5q1SN(w8k0rJp&uuECe zGP?MO_)4V$1^b-}%WrRA9NU{Q<Q1GLnG<R<@{MeN%2$c1sO##<fnB2<7uW_VW~p1J ziT*vQ?f&6qEh`(4DT7cV+pw(TZB79rh=`Sx<^fTE5HDh!ub5wT&}d4(&upBA>9lBz zR*Tzk$xvmXJk8wo#hz(^{LKiO<$3#cF~vN?j1Z#=pctFX1zWHq!Lh|yops>>lkK*@ z*-B>^T29q?ZiTIDiTUz*UgNup1xeg|(Ho5~Myh{f`^y%8b#--x4;V>4oA3jHJO8dr zp%K5-#;RF<v8x->$f0KGjA8-UIbc0gAgjLo;R9xWYmeSkRY~d2wT!lP-(8t&9=sge z>1x-KnY|`A0rd+B3bIwl)c!rLtfr>>m);JiDRC#pEc}UL%Cqr(YHq8`$gl4~Q8#aW zi?Kg{f1z){1OcL{un%9|9LAZ?K6?|?3cG8_MKOSt@U=K0DeJB}klvD0p`yd&bOaZ1 zIdp^#*+R@$i_7FqS3@N)AJa8<`Tmt!vMcSm8GaeJet_dC=rCPfw=KZnTiXO$e*#q% zySH4kH}G8<c#_lj_4oozaA;8Ei%$>Y%XK#YC>L1I^{qaRIeoDUW7c#AdN1mg&+4`q zO=$~V{_tyJu77x?Xef83dVgBZ_zsvytdDL>?Enb@;*-jhl-1!D%{L}@riN?_z;GjA zN(y8p{T+!_Y3j>>_%ol=rodiz9rxa2QCyhfG4g&s(D;t=^_$mECpS$OlSH3CnJOJ< zp%bzD_3VdB>h8v}i=dc(&({;lJ_k)b7SvK3vp^v6RMLFU4~6^yz|#r0hzMrku?y$V z(@a!9FLN5mkNCtYhRnA48rNrwr7%-07PcKK0Wdh*=$bfFu#pRv)_}7Xd5zmD6X~zh z=w<Y^dG28L=IvI=)#m26D>FZklDU;?$zw>AM@v{6?~t)V)oAJ4gzIfKA5R%|)sCvI z*AB#8Ds>##YUL5YtTs@w`u?%87pWS)l5SY~)CFpb)_oHeJbM7oIQHfgtMjIC>K4q; zaUW{bejhz+P_sz04c5@HmY=gVKOBM?)l@8Nt@ro}CY~!JduYc2m=^q}!kXQU81a1F zcjd0wKm>q0QR|jf35sn2jv!-JuuPMDVWJu`I$-+KEEK`PxgmIC`Q>(g`IB-Hjr=AD z%zi)g?LB}Jw2ynxyxr&FvRB(vPM@2FPzpNK44mUFyI)<PNQ>P2MJ+{mI}*0Na|uts z`E}cAYOC~UwSPn#efHM&cn0i@dTH#*-gjh<zTMqiXpg(8ozu!b^u^}tk$VMW`x+V= z?0>=yY0`7+*Ipu!KYxE!fMHo1FWIYYru-*hZ>4JckAoyf5X1Hw=Y>Y!+*WMW%1o<Q z)famsM01h7ffQ~_d_|GrayQ!hwI#HausT+$x$ZoB>3i&+C+vQkpgwA=OLQCAnkSm{ z%YSj`dkO=B>8Il=oTjw;v)aRnt6M_k65bpqfFNFf5poJV51`}lfu!dq`u_c%oScGM z95lJ_Ia8=IKbB`0lRr9Do@-E38GJ-XEYP@x8R1SGU1$n+Wy>Ny(S3tu%VWNTuU((+ z$bvp6BB)i4m%t8sb=FWfA1sb!O}5_--Q2kpXiPGzu3OQ|G&pq|)bW+uwUnKr@e1_k zS)~XQ#$xr_R*t>X?FOkYZ4(|q>ulbE8^~lM{!>rP%#0_YwCghGH^Ei#&Z)|mzl9vz z>Iou-N6Lk6w&hvkDcR1S7qsb_VP%;eK@I1@LfEA$+u2Hgfzw@rvkGV^WbYnm`_cB% zIoCoB*O2(BaFY`iXlUQ1+T`Z3VlQ~&9iSlp)CdgNlzam7`$@!sx@tSNtu<>sU6z8U zseSb^ZV58}X#u^7+5+>HrLh_X3i;4l9rSz#o@#2p-_XG|QQr3Bm8+P8`E^#wJ<l{< z4-XGyn=S?DuFjG4MGLV+!nhOitMBgyxuPhqpz~{>tbNXSx*&B5_nMoqzr7jiD@MoF zG7JN};Nv96jfiC1fb?j7LlaLw4k2rqMsY{X@9r`6yMbV{@IbIAEmaXDB^m}<#bu;| zZO5OP;Z5%N&FC2J;u9BGFnDC>%y;^O$(wWIL{CqAAT5t>{_bLNfZVa%y%mgta>1?N zzaK})J@qk`#I3$0ISi}}*BVy=^qae9Tf<!pK@sJt*_m*YET<2@(4$qB?FI}$ayOC8 z14d^bVt9>id2PAje1a(SXsS@JNxG;^LCsGpnjyW*Z;)y|!pPML8Du=HYLBLK2bYB$ zho)u&6#qmh^E2o<YsZ{sF>p@%avwTz%tOzKkt;FT#WZYezTI-hfguAOZV-=Qvs=s} zMxOC|MsCHJ(s9qYg%=Y(+Aj2Fl(|ywSk2hAdPv^z{$(-m+(u5nq*f6Up{|Gq3#!X` zJpRi5MjFo9ybI4?a7ObRM<*`LuBKacvdYoVjig5+w|*Bnj-x^qL2#x)rk>r3Uqj@` zwO$r!3@@_jJEpw0-ifhhN=4JK$_JOv$B-iqZn&x9dw8(9j_C=b$`5wu<3Aeb#XN+P zOdZZlQ!s5{;j>|X)uWX2E?$J*=dHgNrAeIUCbI=}{m|hv^i+A;lU8_Vv`Sq+D)raW zXwLg5_le(^3FL^jszpLjr89r!{g{rB@P*rU*Y5;887NSP9Cu;>G!+QoEIA%XBBz=Y z-NbgU??ZFT?3j`JB%X7AvBhHxA_I#@-}-bbLSrI#KE#}f1)X16WX2dwf&4Y5#M`k< zB6jMF`l!2i-@Si-QCBx>IjF>;ujtolYwd;|ji{1r(2ABz6+T$#C;s{z_x+aV9p~B4 z-1w^d6C72}FcpXF7%K+FE#^yJ2ccXXJ5u^CF-dwxaihPmMNRV1`!?`#_Cjvp!sawF zA!9!gByi2cNG0&^-o&|lQ%wCNZ?$E(iQSP|P3`>y-JwvTKA*;&`1UV8dm+NM10dx> z7&jyZ2X6#BZ&MmYHEg~VcoBkoQMFv|vpnL#shZ$EQSVP&AEzgUD0#!*L8hGzvX4Ps z<kZI3v#?t{Ts*~Rn8fG{AWZ03YYws4Gb(7%ZY%R6PjI?O6;C$vcZc>@PDQ`^JRir9 zZMXEQ`8?-zLr1O89RU|Kw}O>V5VDgH?Ye@#%!f5m%gQC5xR07MA)5<*nkJhI1<F&e z3@K`NcbS$N3xc*DC1MT?Frewq8$N@zKL00^P%v7}r_=lC0q{IaQkJlgTzSVtkGap4 z!>t&3?Li=mK-p8M@Cy@C&U@X!0K$`)+9M$7j}U_W?m;K(Xz4UJ6&-=sZ4td5t^rAB zbno&xUDGV3bbGXscwer*H~n3?vMH#}9je=45Ylc59NVRUvdf+}!Htw`6p`n56NWh` z>QC>9WW^g(2Q+uxtQO_gf)P8k?BerHY_(o%>AEO_O|5tT6qhEJW75lc#dX-e%;-6O zwJ4Wej6;seT0<ki!_ymy49vcFpt+bNVY~?1%h%kogyF5@pC_*V7qc>stHypj-Fzam zTIs#j{CelUGE=^0RkYdkRSuDUQO~K&ruR_t)-sAqRS2a+5MzgYXo~Nj$cZrgWbj9G zxFXz&=G?i<D~bxrBV>8+<kOr)-gR47yG{u^*6inx9Q1XhmG0FYqueOaX&7~+x6gqh z6R6dJWi+Tm-I?g`pIkXi_&#$X(5EiC-7@mQQ-g+d@`;cc>IP#D0x1%VkXV}%nnWaC zI+l3HWpOZ70VI|mQ&UOZU`#i=9_v~^$WzWRrr^BBsv&jNV<g7ZJ320Ix0gW9qeG}L zZ&?ZOKf`DI#mJ;nY8^Dr4S}M@BllbM7JJVcb6DO3g7h9rZ;@^(+9Z+R$s$F&?N3f~ z{Q{t0hai{mQ}y19+G?tEsNI%sj1BD0%NXL=M<I#bUl+hZ4+e+tvT3I^r#(O!+8s*T zIyt4*1sa0I2?bukAQaF0OAjP&0<KJ7>;|(tG!%mtVNY4`NHZS(l6~r_C=UU`!<=6$ zSH6gO5jJ;-{oSegXrVcni|wtg(ea%bOoB#UjiFBV=-e;XRvfKsHDBC=6-D>Ek;Yn{ zYHZRRDN}bGwK@*>6yWIlzjb2NJCesk8*5f>mV(&@eobIVspI9=#t-8vS0L`71k&Z& z(8+b=dZ4P3B3Is)riuzg4GJyF4Iz-C>@-jbI1W~BrpFLD%{t!W%2Df}LesG`H8wzO zbcpek?uJ5I)WH|kmB!5C+~7;H#lu`!O!MOwhHYZV?E5v}y-gAXwi|Z`YlxkFh0W~$ zcrQ_kC5p=LG?NIO@$Xi2PGHE-{Kp~=LkaQmhK+JcZW9yrPZp>k@{nhO(vElBFOx>A zcxGPql_b59TpX!T&R;W60m1z8B!}|+&nzIW$NnZqD06cbx5O@ktTbV567<yB`@Z|F zf8u_SaCxzsrsg1l<?PeBhHiA7fk(YxMJ!Z5R74zDVeS`#8eU6TaT&A18VaCyQ;ANr zmEGN6Rc>n7MSRXL(?|L(lx(Pt1Io#%GGiu9Ol@lY^zW+*fgMI0WtDp838!n#yM`ST zJ>5Slh(CD8L1a_g%7w~?{!IJLxj#R?&I^aEoqr{4-6gGkg~;Vv<+k$aK@V%n37R0l zCH=TlHoLl){^WCv(k**Fi~-*-@#zE!Z6+8*3te664CQ-cdy)qB>|hnX(VQx$+w&cN zovp%RfAwmpmx{sKj8cWAy46awq2spuk6th<I#?kxQ?oesR+f%@;A>OjuP|3nE2fq2 zhQ1lCZX+IL$So`H!LG}!R3WUw8e)bwt+!tkRL%z$jdaFTwX-CKW_fOw-ZpaU7Dmpw z>?OL{4n!_bm2W&tQo*eLY0Ez|YCP3Y1ZWp-sLCz*Xa{?{;t}-bq-ls;ptcgz$55$* zd+7GMBl~7%ES)^n+hddxo*^-81SZEytSRvTlZ=X63A8TVu<uN&k@Ir%oEhDgXk-RY z3;-~uKzGg@s#u@l#RJV=!)?=YuHuDr20VWtaB=5wwG%U@wTTkn%_5>iBP3e=*n_Qd zpHI7>G!{(+t;4pAmupt)KWdKqtQ_NV@;61H`<`lN()_oS+3jsD^*Z>qWj#||Z|1`F z>5AP^Ox*h_k+)zZw$yL%tXXXT-R;rJsj_kGvL`e<Pp}VVbuu$B@Prb<CMD0USya<f zSxa41R&Nq&+7wW^hzm`i=WR^}#MO4mz+ztXt8t!zFb6(DvlGj65EThtg1La?idz2o zRkGn+p|?Amy4_aJDyF#qw1*ygE+G}%7Te1Jl8R5rYaIxGG#hQWqKA2$I)WhnSwAr( z94bD@iJT{ZQ(I*1uE9aI!_QbIL2GbaO9Z?Uh#f%E6!T@xUxMId#0Il+;E%#vJcNq% zAbwm+)8x~B6;Tvnr|)V$x*#jDQH=4Y3gJ&9ljB&mg|d;6T{q>6V{+-@Tst*Ndusws zSH6RDTjbVG#U7it`yh_iAoxoCzHF$(wK8eQp<0X12grT$-a}7mQeL>wWQ5Q`Avtb_ zFX>m>;S=3PbJCfX^SH#F%cYsh*Ymwxlkzj1c2>hl<r&t-*^I*3ZLE=h&n4}#?%-!$ z=TFGu3Mko-&OUu9GB2t4tykhOzvp%}Cpo>hAWzldwEuCH8u1I`i)gA#Rr9{{TWINn zJFL?_Yv0)ew9LyvfSrt*KcQVg$oPf!Udj<bP#-@Y=4)f-Sg9Qn;(9AGB1G{Up5SW| z_M{AgzE8#8!B<-!0A385$5nB~4hK<E!mBR#NhOhg1sUxM5`WNpe|wuV6`h(uXa|94 zcZX(Un?JP|p-{oZcQQ{d2!)O@`q+$p<@#oOQ`fY5eR&VmjM~I%F#=ARIQqptKH)fO zW6j;8f;EwUyAKmDS!{b-p)I6nTrJwLy!&A{bcfDQ2yU&gR8`Gi__A6~J!Wh8<;|(M zu^La*#_v6lG7mJo4w~Z76)j@GS(IiuVm2E&UQ*B*E7?}Bj&3`F@dE^r&jGQ~9=zXk zYpr;Vgn23##5>j)cldq%+E7Aw!EM|{LH*e(K=m_)Y=CqNo9)t66k@!#lNKDy6+bmL zHQ9Ea(F{>|Sr$dhodWb+A{+NQDT<)sh~Fg;_xB2$k_|xa)z!uiYSB~2PyJ-o+U^*3 zL~RYuKyO5T<r6*W$}?(BzRr`s4B20Y>%J{^h|00AO1ld(w5d3KR6cRLRT)Zga?`kt zD_sYkzOX4fYpI%yge+b{=OB3|naSzv17z{zsmuj-P21rw?Q3RD9K4zx?h7xs$Db!x z$OTQ)8~>Dh@(l_LsF{|JpTxWu{T;~j#@2MJtrJmYw?hkPiaQ}7!=ef}G$S>vEttvO z9#`Quvz(DbX)l5vd#1*1ymDrY654L+n-rDF{{6C7)$01y0){mxPUG6S4SLR1aXL<N zt#ehQQWmYZ7-q){9J>t@m#4mIc?DPITn=!bp5r90R9JB+KJ1v;Yer0q<Qe+jwz5=A zOloDoL#u`{=iYY)iN1U_l^_ZE9z+L#fOKNb%)vfMog3-Z02}|1O-Vvp2+=&j^~f`w zsVhru0#dyWU)s>Yu8e%+2Ki2nW9~kR$`zHNH4YosT|@vM;HmnUzJ5d=$!4gU3N8R! zTYxHU{P9&bNOQ^2Ilm6uR)Trki{74``M#Vs+ED>2RFo|fxz<Ug{B>y#K*SuwKRVQ4 zryVh|klPF48KROOoN#9RSs?JC^MRS8x|P{43t0<E7IBlz1jNqWqfs4+;tvkCayXOS ze_U-YU+5S%_5O`GFOt=}^gb;mRuFHCXAr2g)Ne^{^Nn$qt<CaQI5Ev$ldUf#6Zk$I zBhb(IvZ|TV=U_17Je7PU>$Taf7RV)qK0Syo$St+#g|;cGToIP-@9CDj6P{q#bDro* zJpl-IY3R=1Vq$U7D#oIaxil29EA5qggb@2XxMulCoyW9av8p>nZ?w;@!QMFc6Z?eW z*Jsp&eb3D(hh{FjCr|#gM7AL53>z=XscLVpARe`JIu4~3=3EFXT0lJeJn{mApDsks zXT1{xUtRE!wH5oB`zZMmGCa(E_OsDgUzVm}RX>ia;+^+w*N0beC&2HJe=^$2(z4}D zjL)wW86PB1LSA6ZDRv!DOi)rP-jmT12i8q38AHVG94?R%$@M@j_2=qqqSm#^)6&wI zRU~N6Gmn(mL<&uxi!fartjU0iGia`}h=F}UNpaxw<CjI=nk_GL`x~AZYEIA0C?9bn z?3_4Z1oKlHPg@W*PG5z*PR(-1?&J;Or@uZkbMj@1`b3M9Nb8h)g@refR(cd4vuA5< z6~Wex@ebPOT!nw`DETIx*o*uLi(uu1$ZTGum5GT}>HT~6+=17pCBuB^948_VgJU4L zQ*?Z@|9+C#D(d8{^;6%+x>}RL>Y(bp&N<8CK_2n;Dnj-z$sE}eL>W+<fU1PO3g=}< z{w%56@MuWU#5S8B0&IzZEdyp0_+Khva9KVHTq%jzRt1G9jbAYh0|NsS6BDT)q1GKG z{<?AX#iMfnki&42^E*b8#K(_1Jw2U^@jS5FEU+5~lT<5yooV7+K+2zke*lKLZiStL zqs8lP!=Pk4J(*uNNfkf_`5DHnP!C?n%o!lK$qtPNF1{md0AZ|69>jV`diOr=memJC zg7+%dJZ2bjp=ERm?QMf@O$U+^LTM5WnB3uc&5qB()t|u6bc%wU%uhg39||^wwQv5< zrvRhhVW~J3#%2-UE+2FX_;iwnjaHVVPf(#1sPL|r);1?Qcmou4UB&AN_~YbVGL*k9 zaOCOXi;CJ8Ip{%S;hsK)BX2z<n02ALAe2QM8vcqZ%zimUTr)snn5UC-l&sE*mo5mo z^(LqK1zc&uc8ss08X`eIL@P9*_1<{~!UocZ1cijqF_jJJBr5!<K(vtw^tr2Ji*2{` z=xlB6u|MVrx6<*n)Y8hTO&a_;vTu--4+S1&Q#g{!=b+hKP)<RiO&au|a5c)3EAszu zIRPn?(F_j{$5rvVU4)whZN?ZK95giS-{0ZRlP;&`Q8R`6msO8}8^T_b)zXS`RbwI} zOFWb?@WEH1_z(!w1?|6pgFgz-p$-8ThPP~zDv9B`a^)_GgcsFS$|w6rB?YyVNH|~p z93WU+TwD|{FM~37KypHZBn8M9RL(z1()L=E4TrKya>tOm_<<4^`2FG-xYf8g?qeV~ zsja0T(fV3O7STquO(0;AZaGaUwimNqQc?mc7mzP>^nj8U%P1WfQC==u5s>E{43An} z25p4YW`<y(jthh>`A2efa=NWm?%)4MDiPIQXx0Rk4%r}=h_&A(=@zg$92~lGpj)UL zhX;gJx`aSrV4m+5$WoDI8bmNxoTRFX{7W$#NfK$as#0_4R=|(<<E;3=LPL{wn*L5p zGRTOIfW~SPYG0I<m3!6f?!lnC{`!n^J~HKk8`<hqCB@mfxjB$_3!a68mX;&ZI^_r% zO1a|z3i3Hvdk&&r-Ov9Ex?E~$lO8#Zav6uN7)kGE1HKT1+P0Ni!fcxwCOAvK_*1{q z2#8m%U90T0yTu3qTh3`Abe4(*w`ON(7A{@@Ll}An6M=>jxy8oJ%$xpFDp^#u<vZw} z&{<JNGdeBW@-V;E<BoGyjXFG!FeN&rhsuKJru_D|$n}G|I_bo+4$?g!+&a)k7Wdfx zaTg%#Ds}o)<^iu9j{hu1Kw|cPwl?hl-reEa4zKM0Q}uViMn*;?TCU+F;S&_3E%6u` Ya=+%P*uA?xr1z{Sr+P2{?xVl|9|=c9`v3p{ literal 0 HcmV?d00001 diff --git a/docs/user/guide/providers-models-page.zh.png b/docs/user/guide/providers-models-page.zh.png new file mode 100644 index 0000000000000000000000000000000000000000..b5d1f4597ca57fe96fdd7305f2b52d972c8d2efb GIT binary patch literal 70021 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 Sw>7+jl9N%sl`DP!$$tSSuJy+N literal 0 HcmV?d00001 diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml index 04b27adb51..8a3f518eca 100644 --- a/docs/user/guide/providers.i18n.yaml +++ b/docs/user/guide/providers.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/providers.md -providers.md: 2ae1093699d8eb26171a2403db155113d84e437e -providers.zh.md: 6ce513c659140ed18716bd5c8f75c428ad981f2b +providers.md: c555e62d5343ccc758c0c6e699d30ffec229f2df +providers.zh.md: 6c4154c86db3d95c6b083519533954fc4cc90e45 diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md index 2ae1093699..c555e62d53 100644 --- a/docs/user/guide/providers.md +++ b/docs/user/guide/providers.md @@ -17,12 +17,16 @@ Adding a provider therefore rarely means editing `cordis.yml` — writing settin Start `pnpm run dsh web` and open **Settings → Models**. +![The Models page: the DeepSeek card, with Add provider and Add a custom provider below it](providers-models-page.png) + **Give DeepSeek its key.** The DeepSeek card carries one API-key field; fill it in, save, and the provider is ready. **Add a provider from the installed catalog.** Choose **Add provider**, pick one of pi-ai's catalog providers (anthropic, openai, and so on), and enter that provider's API key. The endpoint, protocol, and model catalog all come from the catalog; the key is the only thing you owe. **Add a custom provider.** Choose **Add a custom provider** for a route the catalog does not ship — a company gateway, a self-hosted server, or a provider newer than the installed catalog. It asks for a Provider ID (the lowercase identifier that names the route in requests and as its credential), a base URL, a protocol, and at least one model. +![The custom provider form: Provider ID, display name, base URL, API protocol, and API key](providers-custom-form.png) + **Let the endpoint report its models.** Expand **Model catalog** and choose **Fetch available models**: the interrogation asks the endpoint **the form currently shows** — including a base URL edited but not yet saved and a key typed but not yet stored — and offers what it reports as candidates to pick from. A route the installed catalog describes is answered from that catalog with no network call. Adopting a candidate only writes rows into the draft; nothing is stored until you save. Keys are write-only: the page only ever holds a redacted descriptor, never the literal secret. A key you enter is stored in `$DSH_HOME/.env`, and the profile records only the variable name that references it. diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md index 6ce513c659..6c4154c86d 100644 --- a/docs/user/guide/providers.zh.md +++ b/docs/user/guide/providers.zh.md @@ -17,12 +17,16 @@ Harness 出厂就带 DeepSeek,同时挂着一个通用的多提供方适配器 启动 `pnpm run dsh web`,打开**设置 → 模型**。 +![模型页:DeepSeek 卡片,以及添加提供方与添加自定义提供方两个入口](providers-models-page.zh.png) + **填 DeepSeek 的密钥。** DeepSeek 卡片上只有一个 API 密钥输入框,填好保存即可开始用。 **添加内置目录里的提供方。** 点**添加提供方**,从 pi-ai 内置目录中选一个(anthropic、openai 等),填入该提供方的 API 密钥。端点、协议和模型目录都由内置目录提供,你只需要给密钥。 **添加自定义提供方。** 点**添加自定义提供方**,用于内置目录没有的路由——公司网关、自建服务,或比内置目录更新的提供方。需要填 Provider ID(请求里点名它、也作为凭据名的小写标识)、API 地址、协议,以及至少一个模型。 +![自定义提供方表单:Provider ID、显示名称、API 地址、API 协议、API 密钥](providers-custom-form.zh.png) + **让端点自己报模型。** 展开**模型目录**后点**获取可用模型**,会按你**当前表单里**的地址与密钥去问端点(地址改了但没保存、密钥刚输入还没存下,都算数),把它报告的模型列成候选让你勾选。内置目录里的路由直接由目录作答,不联网。采纳只是把行写进草稿,最终还是你点保存才落盘。 密钥是只写的:页面拿到的永远是脱敏描述符,不是明文。写入的密钥存进 `$DSH_HOME/.env`,profile 里只记录引用它的变量名。 From 2426be893a7200bcd3d508ab30704d370a8c61c2 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Thu, 6 Aug 2026 20:21:17 +0800 Subject: [PATCH 30/67] fix(scripts): let the documentation site carry its own images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An image the manifest does not publish was rewritten to a raw.githubusercontent URL, which serves a private repository to nobody: raw answers 404 unauthenticated, and a github.com session does not authenticate it. Every image on the site was broken for every reader, and no gate could say so — link checking asks whether the file exists in the repository, not whether a site reader can fetch it. The projector now copies a referenced image into the generated tree beside the page and points the reference at it, so Vite bundles it like any other site asset and repository visibility cannot reach the published page. The copy lands beside the page so one relative URL is right from both locale trees and a page's assets leave with the page; two sources projecting onto one path throw rather than letting the last copy win. Canonical Markdown keeps writing ordinary repository-relative paths, so the same file still renders when read in the repository. The guide this was found through is renamed to its subject: 配置模型. --- ...8-06-doc-site-carries-its-images.i18n.yaml | 6 +++ .../2026-08-06-doc-site-carries-its-images.md | 39 ++++++++++++++++++ ...26-08-06-doc-site-carries-its-images.zh.md | 39 ++++++++++++++++++ docs/user/guide/providers.i18n.yaml | 4 +- docs/user/guide/providers.md | 2 +- docs/user/guide/providers.zh.md | 2 +- docs/user/guide/quickstart.i18n.yaml | 4 +- docs/user/guide/quickstart.md | 2 +- docs/user/guide/quickstart.zh.md | 2 +- scripts/project-doc-site.spec.ts | 35 +++++++++++++++- scripts/project-doc-site.ts | 41 ++++++++++++++++--- website/docs.ts | 2 +- 12 files changed, 163 insertions(+), 15 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.md create mode 100644 .agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.zh.md diff --git a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.i18n.yaml b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.i18n.yaml new file mode 100644 index 0000000000..75018c8374 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.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-06-doc-site-carries-its-images.md +2026-08-06-doc-site-carries-its-images.md: 21593c2cadb6b2aaf52350ab61156ad892bc4163 +2026-08-06-doc-site-carries-its-images.zh.md: 54aee878a9d0f16d1fe3b219da7b248fb5148fa3 diff --git a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.md b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.md new file mode 100644 index 0000000000..21593c2cad --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.md @@ -0,0 +1,39 @@ +# Agent Note: The documentation site carries its own images + +Status: implemented + +English | [中文](2026-08-06-doc-site-carries-its-images.zh.md) + +## Problem + +`scripts/project-doc-site.ts` rewrote every repository-relative target that the publication manifest does not publish into a GitHub URL, and for an image that meant `https://raw.githubusercontent.com/<owner>/<repo>/<ref>/<path>`. Nothing in the site build copies files: `srcDir` is the disposable `.generated` tree, VitePress sets no `publicDir` (its default, `<srcDir>/public`, is inside the tree the projector deletes on every run), and only Markdown is written there. + +That works only for a public repository. This one is private, and `raw.githubusercontent.com` answers 404 to an unauthenticated request — a browser session on github.com does not authenticate it either, since GitHub's own UI serves private blobs through separately signed URLs. Every image on the site was therefore broken for every reader, and no gate said so: `verify-md-links` and the projector check that the target file *exists in the repository*, which is a different question from whether a site reader can fetch it. + +## Decision + +`rewriteMarkdown` takes an optional `placeImage(absPath): string`. When a page references an image the manifest does not publish as a page, the projector copies that file into the generated tree beside the page and rewrites the reference to `./<basename>`; Vite then bundles it like any other site asset. Nothing about repository visibility can reach the published page. + +The copy lands beside the page rather than in a shared asset directory. Each locale's route tree gets its own copy, so one relative URL is correct from both `guide/` and `en/guide/` without computing per-locale prefixes, and a page's assets are removed with the page when the manifest drops it. Two sources that would project onto one path throw, in the same spirit as the existing duplicate-route check, rather than letting whichever copied last win. + +`placeImage` is optional because `rewriteMarkdown` is also called directly by its spec, where no generated tree exists. Without it the old GitHub-raw behavior stands, which keeps that seam honest: the fallback is still the correct answer for a consumer that only rewrites text. + +Canonical Markdown keeps writing ordinary repository-relative image paths, so the same file renders on GitHub and on the site. No document carries a site-absolute URL to satisfy VitePress. + +## Alternatives considered + +**Set `publicDir` outside `.generated` and reference site-absolute URLs.** Fewer moving parts in the projector, but every image reference would then be broken when the same Markdown is read in the repository, and canonical docs are read both ways. + +**Host images on the assets branch, as demo GIFs already are.** That branch exists to keep large binaries out of the main history, and its raw URLs have exactly the same visibility problem. It remains the right home for recordings; it does not solve this. + +**Wait for the repository to become public.** It would fix the symptom without making the site self-contained, and the site would silently depend on GitHub's availability and rate limits for every image. + +## Consequences + +Images in published documentation now work regardless of who is reading or whether the repository is public, and the site build has no runtime dependency on GitHub for them. The generated tree grows by one copy of each referenced image per locale — the four screenshots in the model-provider guide add roughly 270 KB per locale. + +Images referenced from *unpublished* documents are untouched: they still resolve to GitHub raw, and still fail for a private repository. Nothing consumes them today, and a document that is not on the site has no site build to carry its assets. + +## Testing + +`scripts/project-doc-site.spec.ts` covers the placer receiving the resolved absolute path and the returned URL landing in the Markdown, a published page link still resolving to its route when a placer is present, and the unchanged GitHub-raw fallback when no placer is supplied. `pnpm docs:check` builds the site with the model-provider guide's screenshots and fails on a missing source; the copied files and their `./<basename>` references were verified in `website/.generated` and in a running `docs:dev` (`naturalWidth > 0` in both locales). diff --git a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.zh.md b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.zh.md new file mode 100644 index 0000000000..54aee878a9 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.zh.md @@ -0,0 +1,39 @@ +# Agent Note: 文档站点自带图片 + +Status: implemented + +[English](2026-08-06-doc-site-carries-its-images.md) | 中文 + +## Problem + +`scripts/project-doc-site.ts` 会把发布清单未收录的仓库相对目标一律改写成 GitHub 地址,对图片而言就是 `https://raw.githubusercontent.com/<owner>/<repo>/<ref>/<path>`。站点构建不拷贝任何文件:`srcDir` 是用完即弃的 `.generated` 树,VitePress 没有设置 `publicDir`(其默认值 `<srcDir>/public` 恰好位于投影每次运行时删除的那棵树里),而写进去的只有 Markdown。 + +这只对公开仓库成立。本仓库是私有的,而 `raw.githubusercontent.com` 对未认证请求一律回 404——github.com 上的登录会话也不能认证它,因为 GitHub 自家界面是用另一套单独签名的地址提供私有 blob 的。于是站点上的每一张图片对每一位读者都是坏的,却没有任何门禁能说出来:`verify-md-links` 与投影校验的是目标文件**在仓库里是否存在**,那与站点读者能否取到它是两个问题。 + +## Decision + +`rewriteMarkdown` 新增可选的 `placeImage(absPath): string`。当页面引用了一张清单未作为页面发布的图片时,投影把该文件复制进生成树中该页面的旁边,并把引用改写为 `./<basename>`;随后 Vite 会像处理其他站点资源一样打包它。仓库可见性再也影响不到已发布页面。 + +副本落在页面旁边,而不是某个共享资源目录。每个 locale 的路由树各持一份副本,因此同一个相对 URL 在 `guide/` 与 `en/guide/` 下都正确,无需按 locale 计算前缀;清单撤下某页时,它的资源也随之消失。两个来源若会投影到同一路径则抛错——与既有的重复路由检查同一个立场——而不是让最后拷贝的那个静默胜出。 + +`placeImage` 之所以可选,是因为 `rewriteMarkdown` 也被它自己的 spec 直接调用,而那里并不存在生成树。不传它时保持原有的 GitHub raw 行为,这也让该接缝保持诚实:对只改写文本的消费方而言,这个回退仍是正确答案。 + +正本 Markdown 照旧写普通的仓库相对图片路径,因此同一份文件在 GitHub 上和站点上都能正常显示。没有任何文档为了迁就 VitePress 而写站内绝对 URL。 + +## Alternatives considered + +**把 `publicDir` 设到 `.generated` 之外,并使用站内绝对 URL。** 投影这边的活动部件更少,但同一份 Markdown 在仓库中阅读时,每一处图片引用都会是坏的,而正本文档是两种方式都要读的。 + +**把图片放到 assets 分支,就像演示 GIF 那样。** 那个分支的存在是为了让大体积二进制不进主线历史,而它的 raw 地址有着完全相同的可见性问题。它仍然是录屏的正确归宿;但它解决不了这件事。 + +**等仓库转为公开。** 那只是消除症状,不会让站点自给自足,而且每一张图片都会让站点隐式依赖 GitHub 的可用性与限流。 + +## Consequences + +已发布文档中的图片,现在无论谁在阅读、无论仓库是否公开都能显示,站点构建也不再为图片依赖 GitHub 的运行时可达性。生成树会为每个 locale 各增加一份被引用图片的副本——配置模型指南里的四张截图,每个 locale 约 270 KB。 + +**未发布**文档引用的图片不受影响:它们仍解析到 GitHub raw,对私有仓库仍然失败。今天没有任何消费方用到它们,而不在站点上的文档也没有站点构建可以承载其资源。 + +## Testing + +`scripts/project-doc-site.spec.ts` 覆盖:placer 收到解析后的绝对路径且其返回的 URL 落进 Markdown、存在 placer 时已发布页面的链接仍解析到自己的路由、以及不传 placer 时不变的 GitHub raw 回退。`pnpm docs:check` 会带着配置模型指南的截图构建站点,并在来源缺失时失败;被拷贝的文件及其 `./<basename>` 引用已在 `website/.generated` 与运行中的 `docs:dev` 里核实(两个 locale 均 `naturalWidth > 0`)。 diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml index 8a3f518eca..324bcfb5c3 100644 --- a/docs/user/guide/providers.i18n.yaml +++ b/docs/user/guide/providers.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/providers.md -providers.md: c555e62d5343ccc758c0c6e699d30ffec229f2df -providers.zh.md: 6c4154c86db3d95c6b083519533954fc4cc90e45 +providers.md: d96cab0fa09583d81d98863169819fdd78d636e7 +providers.zh.md: d413fec2f9d703e31e82e50fcbe83b24bd58ee39 diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md index c555e62d53..d96cab0fa0 100644 --- a/docs/user/guide/providers.md +++ b/docs/user/guide/providers.md @@ -1,4 +1,4 @@ -# Configure model providers +# Configure models English | [中文](providers.zh.md) diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md index 6c4154c86d..d413fec2f9 100644 --- a/docs/user/guide/providers.zh.md +++ b/docs/user/guide/providers.zh.md @@ -1,4 +1,4 @@ -# 配置模型提供方 +# 配置模型 [English](providers.md) | 中文 diff --git a/docs/user/guide/quickstart.i18n.yaml b/docs/user/guide/quickstart.i18n.yaml index 74fd06f83d..257f4919cc 100644 --- a/docs/user/guide/quickstart.i18n.yaml +++ b/docs/user/guide/quickstart.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/quickstart.md -quickstart.md: 8a9ed716d9395448aadfb97d0935bd42ee06e6c1 -quickstart.zh.md: 3652b0453f870640b278ce6f1355e67e85983ffe +quickstart.md: e81e0ff57384156ee2963d4788519d2384c362ea +quickstart.zh.md: 9755da8bf078c817f9c2a00134360b5169c536bf diff --git a/docs/user/guide/quickstart.md b/docs/user/guide/quickstart.md index 8a9ed716d9..e81e0ff573 100644 --- a/docs/user/guide/quickstart.md +++ b/docs/user/guide/quickstart.md @@ -57,6 +57,6 @@ headless-agent uses the `@deepseek-ai/dsh-cli-demo` app. `dsh web` instead compo ## Next steps -- [Model providers](./providers.md) — reach providers beyond DeepSeek, and custom gateways +- [Configure models](./providers.md) — reach providers beyond DeepSeek, and custom gateways - [Configuration](./config.md) — understand the `cordis.yml` format - [Develop a plugin](../develop/basic/) — build your own tool or backend diff --git a/docs/user/guide/quickstart.zh.md b/docs/user/guide/quickstart.zh.md index 3652b0453f..9755da8bf0 100644 --- a/docs/user/guide/quickstart.zh.md +++ b/docs/user/guide/quickstart.zh.md @@ -57,6 +57,6 @@ headless-agent 使用 `@deepseek-ai/dsh-cli-demo` app。`dsh web` 则组合 [`ap ## 下一步 -- [配置模型提供方](./providers.md) — 接入 DeepSeek 之外的提供方与自定义网关 +- [配置模型](./providers.md) — 接入 DeepSeek 之外的提供方与自定义网关 - [配置文件](./config.md) — 了解 `cordis.yml` 的格式 - [开发插件](../develop/basic/) — 编写自己的 tool 或后端 diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index 185acf2db5..c6402d7fc8 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -93,7 +93,7 @@ describe('rewriteMarkdown', () => { })).toBe('[B](./reference-root/b.md)\n') }) - it('uses raw GitHub content for unpublished images', () => { + it('uses raw GitHub content for unpublished images when nothing places them', () => { const { root, pages } = fixture() expect(rewriteMarkdown('![logo](../packages/logo.svg)\n', { locale: 'en', @@ -105,6 +105,39 @@ describe('rewriteMarkdown', () => { })).toBe('![logo](https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/abc123/packages/logo.svg)\n') }) + it('hands an image to the placer and uses the URL it returns', () => { + // A raw GitHub URL cannot serve a private repository, so the site build + // carries images itself; the placer is what puts them there. + const { root, pages } = fixture() + const placed: string[] = [] + expect(rewriteMarkdown('![logo](../packages/logo.svg)\n', { + locale: 'en', + sourcePath: 'docs/a.md', + route: 'en/a.md', + pages, + repoRoot: root, + repositoryRef: 'abc123', + placeImage: (absPath) => { + placed.push(absPath.split('/').pop() ?? '') + return './logo.svg' + }, + })).toBe('![logo](./logo.svg)\n') + expect(placed).toEqual(['logo.svg']) + }) + + it('leaves a published page link to the route even when a placer exists', () => { + const { root, pages } = fixture() + expect(rewriteMarkdown('[B](b.md)\n', { + locale: 'en', + sourcePath: 'docs/a.md', + route: 'en/a.md', + pages, + repoRoot: root, + repositoryRef: 'abc123', + placeImage: () => { throw new Error('a page link must not be placed as an asset') }, + })).toBe('[B](./reference/b.md)\n') + }) + it('does not rewrite Markdown-looking text inside code fences', () => { const { root, pages } = fixture() const source = '```md\n[B](b.md)\n```\n' diff --git a/scripts/project-doc-site.ts b/scripts/project-doc-site.ts index 592bbcfdee..ef821bd00e 100644 --- a/scripts/project-doc-site.ts +++ b/scripts/project-doc-site.ts @@ -5,8 +5,8 @@ * tier, while this adapter rewrites cross-source links for the public site. */ -import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { dirname, extname, posix, relative, resolve, sep } from 'node:path' +import { copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { basename, dirname, extname, posix, relative, resolve, sep } from 'node:path' import { fromMarkdown } from 'mdast-util-from-markdown' import { gfmFromMarkdown } from 'mdast-util-gfm' import { gfm } from 'micromark-extension-gfm' @@ -38,6 +38,15 @@ export interface RewriteMarkdownOptions { pages: DocsPage[] repoRoot: string repositoryRef: string + /** + * Place one referenced image beside the projected page and return the URL to + * reach it from that page. A GitHub raw URL cannot serve this repository — + * `raw.githubusercontent.com` answers 404 for a private one, and no reader of + * the site is authenticated to it — so an image travels into the generated + * tree and Vite bundles it like any other site asset. Omitted by callers that + * only rewrite text, which then leave images pointing at the repository. + */ + placeImage?: (absPath: string) => string } function repoPath(absPath: string, repoRoot: string): string { @@ -222,9 +231,11 @@ export function rewriteMarkdown(source: string, options: RewriteMarkdownOptions) ? options.locale === 'root' ? 'en' : 'root' : options.locale const page = published.get(targetPath)?.get(targetLocale) - const nextUrl = page === undefined - ? githubTarget(absPath, line, suffix, options.repositoryRef, options.repoRoot, node.type === 'image') - : routeTarget(options.route, page.route, suffix) + const nextUrl = page !== undefined + ? routeTarget(options.route, page.route, suffix) + : node.type === 'image' && options.placeImage !== undefined + ? options.placeImage(absPath) + : githubTarget(absPath, line, suffix, options.repositoryRef, options.repoRoot, node.type === 'image') const start = node.position?.start.offset const end = node.position?.end.offset @@ -299,6 +310,8 @@ export function docsSourceFiles(): string[] { /** Rebuild the disposable VitePress source tree from the publication manifest. */ export function projectDocs(): void { const routes = new Set<string>() + /** Projected asset path to the source it came from, for collision detection. */ + const assets = new Map<string, string>() const repositoryRef = process.env.GITHUB_SHA ?? 'master' rmSync(generatedRoot, { recursive: true, force: true }) @@ -319,6 +332,24 @@ export function projectDocs(): void { pages: docsPages, repoRoot: root, repositoryRef, + placeImage: (absPath) => { + // Beside the page that references it, under its own basename: each + // locale's route tree gets its own copy, so one relative URL is correct + // from both. Two sources that would land on one name are a collision + // rather than a silent overwrite of whichever copied last. + const name = basename(absPath) + const target = resolve(dirname(output), name) + const claimed = assets.get(target) + if (claimed !== undefined && claimed !== absPath) { + throw new Error( + `project-doc-site: ${repoPath(absPath, root)} and ${repoPath(claimed, root)}` + + ` both project to ${relative(generatedRoot, target).split(sep).join('/')}.`, + ) + } + assets.set(target, absPath) + copyFileSync(absPath, target) + return `./${name}` + }, }) writeFileSync(output, addProjectionFrontmatter(projectedPageContent(projected, page), page)) } diff --git a/website/docs.ts b/website/docs.ts index 2b9c4654c4..7bc2225865 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -133,7 +133,7 @@ const homeAndGuide = pairedPages([ { source: 'docs/user/guide/providers.md', route: 'guide/providers.md', - label: { root: '配置模型提供方', en: 'Model providers' }, + label: { root: '配置模型', en: 'Configure models' }, sidebar: { root: 'zh-guide', en: 'en-guide' }, section: { root: '入门', en: 'Guide' }, order: 3, From 9ff7eb84f0c3c3ab28ca888db056fb703e2a3ea8 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Thu, 6 Aug 2026 20:35:02 +0800 Subject: [PATCH 31/67] docs: propose API key format validation --- ...-08-06-api-key-format-validation.i18n.yaml | 6 ++ .../2026-08-06-api-key-format-validation.md | 101 ++++++++++++++++++ ...2026-08-06-api-key-format-validation.zh.md | 101 ++++++++++++++++++ 3 files changed, 208 insertions(+) create mode 100644 .agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml create mode 100644 .agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.md create mode 100644 .agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.zh.md diff --git a/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml b/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml new file mode 100644 index 0000000000..f62a18e0eb --- /dev/null +++ b/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.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/bug-fix/2026-08-06-api-key-format-validation.md +2026-08-06-api-key-format-validation.md: dc19baa8b697998df2892f0840a35a8232cc92de +2026-08-06-api-key-format-validation.zh.md: 28073660b1d4868fecf5ce419726d6d997383392 diff --git a/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.md b/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.md new file mode 100644 index 0000000000..dc19baa8b6 --- /dev/null +++ b/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.md @@ -0,0 +1,101 @@ +# Agent Note: Validate API key format before it reaches an HTTP header + +Status: proposed + +English | [中文](2026-08-06-api-key-format-validation.zh.md) + +## Problem + +An API key holding characters no HTTP header value can carry is accepted by every configuration surface and fails only when a request is built, far from the field that caused it. + +Paste a key containing an emoji, CJK text, or a full-width punctuation mark into the web Models page and the save reports success. The first turn then fails with `Cannot convert argument to a ByteString because the character at index 7 has a value of 55357 which is greater than 255` — the index and code point are UTF-16 internals with no action attached, and they disclose the code point of one character of the key. `llm-deepseek` produces this because `fetch` builds the `Bearer` header inside the `try` at [adapter.ts](../../../../packages/llm/llm-deepseek/src/adapter.ts), whose `catch` labels every failure `TRANSPORT`; that label is in `DEFAULT_RETRYABLE_CODES`, so a permanent, deterministic fault is also retried three times. + +`llm-pi-ai` is worse on the same input. Its discovery probe builds the same header with a bare `fetch` in [discovery.ts](../../../../packages/llm/llm-pi-ai/src/discovery.ts) and wraps every failure as `could not reach <url>`, so a local key fault is reported as an unreachable network. The probe is reachable from the unsaved draft: `ProviderEditor` puts the typed `keyDraft` into its probe request, so the model-listing button sends an illegal key before anything is stored. + +Whitespace passes every check. `ProviderEditor` tests `keyDraft.length` and `resolveAdapterOptions` tests `config.apiKey.length`, so a key of three spaces stores and then authenticates as `Bearer` plus blanks. `llm-pi-ai` rejects an empty literal `apiKey` in `resolveProfiles`, but applies no check whatsoever to a credential- or environment-sourced key — which is the path the Models page writes, and therefore the path users actually take. + +Sources: deepseek-harness#1594 and #1595; dsh-external#247, #249, #266, and #210. + +## Proposal + +One rule defines a legal key: **after trimming, non-empty, and every character within `[\x21-\x7E]`** — printable ASCII, space excluded. + +This single predicate covers every input the sources list: empty, leading and trailing whitespace, interior whitespace, C0 control characters, emoji, CJK text, and full-width punctuation. It is also exactly the constraint that produced the ByteString failure, so the two issues close on one definition rather than on two coincidentally related fixes. + +A second, narrower rule catches a pasted environment line: reject input matching `^[A-Z][A-Z0-9_]*=` or wrapped in matching quotes. Restricting the prefix to upper-case keeps real keys clear of it — `sk-` forms break the identifier match at the hyphen. + +### Invariants belong at every layer; heuristics belong where the human is + +The charset rule is an invariant. A non-ASCII character *cannot* travel in a header value for any provider, so enforcing it in the browser, in each resolver, and on every credential read is consistent by construction rather than by agreement. + +The shape rule is a guess about how people paste, so it runs **only in the browser**. `llm-pi-ai` fronts OpenAI, Anthropic, and arbitrary hand-declared gateways whose key formats this repository does not own; a gateway issuing a key shaped like `TENANT1=abc` would, if the rule ran in the resolver, be locked out with no escape — the settings page would refuse it and a hand-written `.env` would be rejected on read. Confining the heuristic to the surface where the paste happens keeps the environment as the way through. + +### Absence is a configuration state, not a missing key + +"No API key" means three different things here, and only one of them is an error. The rule applies to a value that was *provided*; deciding whether one was provided at all stays with each caller. + +**Omitted.** A profile naming neither `apiKey` nor `apiKeyEnv` is authenticated by something other than a harness-held key. `routeAuth` in [provider.ts](../../../../packages/llm/llm-pi-ai/src/provider.ts) keeps the installed catalog provider's own auth precisely so provider-native ambient discovery survives, and `openai-codex` — shipped in that catalog — authenticates through OAuth and refuses an explicit key outright. `namesCredential` exists to carry this distinction. In `llm-deepseek`, an absent `apiKey` likewise falls through to `apiKeyEnv`. Omission is never validated. + +**A blank field in the web UI.** The key input opens empty even for a provider whose key is already stored — the `keyStored` copy reads "Configured — enter a new value to replace" — so blank means *keep what is stored*. `ProviderEditor` already skips `credentials.set` entirely when the draft is empty, and that stays a no-op: a blank field must never block submit, or editing a base URL would demand re-entering the key. + +**Provided, but empty or whitespace-only.** This is the one error, because the user expressed an intent to set a key and supplied nothing. `llm-pi-ai` already words it correctly in `resolveProfiles` — *has an empty apiKey; omit it to use ambient authentication* — and that shape, naming the legitimate alternative rather than just refusing, is what the other surfaces adopt. + +`normalizeApiKey` therefore takes `string`, never `string | undefined`. + +### Where the rule lives + +`normalizeApiKey` is a new module of the `dsh-llm` seam, beside [attribution.ts](../../../../packages/llm/llm/src/attribution.ts), which already owns shared header concerns. Both adapters depend on the seam and both need the rule, so it has two current consumers rather than a speculative one. It returns the trimmed value or a reason (`empty`, `illegalCharacters`). + +The client cannot import it: client packages reference only client packages, so `packages/client/ui-models` mirrors the predicate and owns the localized messages, exactly as `validateDeepSeekModels` mirrors the host's `catalogModel` schema today. Each side names the other in a comment. + +### What each surface does + +| Surface | Change | +|---|---| +| `dsh-llm` | Add `normalizeApiKey`; add `INVALID_CREDENTIAL`, deliberately outside `DEFAULT_RETRYABLE_CODES`. | +| `llm-deepseek` `resolveAdapterOptions` | Normalize a present `apiKey`, throwing beside the existing beyond-schema bounds; use the trimmed value. An absent one still falls through to `apiKeyEnv`. Closes dsh-external#210. | +| `llm-deepseek` `resolveApiKey` | Normalize what the credentials seam or environment returns; reject with `INVALID_CREDENTIAL` naming the Models page, never echoing the key. | +| `llm-pi-ai` `resolveProfiles` | Widen the existing emptiness check to the shared rule, keeping its "omit it to use ambient authentication" wording. | +| `llm-pi-ai` `resolveApiKey` | Normalize the credential and environment paths, which are unchecked today. A profile naming no credential still returns `undefined` untouched, so ambient and OAuth routes are unaffected. | +| `llm-pi-ai` `discoverModels` | Normalize before building the header, so an illegal key stops reporting as an unreachable endpoint. A probe carrying no key stays unauthenticated as it is today. | +| `ui-models` | Mirror the charset rule, add the shape heuristic, trim `keyDraft` before probe and `credentials.set`, and fix the `stringAt` emptiness test. A blank field remains a no-op that submits; a field holding only whitespace is a field-level failure, so typed input is never silently discarded. Gate submit and show the failure on the field, matching the existing `modelFailure` pattern. | + +`ProviderEditor` serves both the DeepSeek and pi-ai layouts, so one client change covers both providers. + +`credentials-local` is deliberately untouched. It stores credentials generally, and printable-ASCII is a constraint of HTTP headers rather than of credential storage; its existing refusal of values no dotenv style can represent stays as it is. + +## Alternatives considered + +**A `.pattern()` on the `apiKey` schema field.** Vendored schemastery supports it, and the pattern would serialize to the browser with the rest of the namespace schema — one rule, delivered rather than mirrored. It loses because a pattern cannot trim first: `cordis.yml` would then reject a padded key while `.env` tolerated one, and the resolver would disagree with the schema about the same string. Validating in `resolveAdapterOptions` keeps every surface trim-then-validate, and that function is already where this package re-judges bounds the schema cannot express. + +**A validation module shared by client and host.** Rejected by the source-plane layout: client packages reference only client packages plus `vendor/cordis` and `support/invariants`, and widening that to reach a host package would collide the two `Context` merges the split exists to keep apart. Mirroring a one-line predicate with a test on each side is the established shape here. + +**Sniffing the `TypeError` in the adapter's `catch`.** This would classify the ByteString failure after the fact, leaving the header construction itself unguarded. It depends on the wording of a Node error message, so it degrades silently across runtime versions, and it cannot help `llm-pi-ai`, whose header is built inside the pi-ai SDK. Refusing the key before handing it over works for both adapters and for the discovery probe. + +**Enforcing in `credentials-local.set`.** It would catch every writer at once, including a hand-edited file. It loses because that provider stores credentials of every kind, and a rule derived from HTTP header encoding does not belong to it. + +**Running the shape heuristic in the resolvers too.** Symmetric, and it would stop a pasted environment line written directly into `.env`. Rejected for the lockout described above: a false positive in a resolver leaves the user no working path, while a false positive in the browser leaves the environment open. + +**Probing the provider at save time to prove the key works.** It would close the complaint the sources actually open with — a save that reports success and fails at the first turn. Rejected as out of scope and, on today's code, unbuildable: `discoverModels` short-circuits to the installed catalog before any network call for exactly the providers pi-ai ships catalogs for, so it verifies nothing about the key, and the DeepSeek card has no probe at all. A verifier's value is distinguishing "key rejected" from "cannot reach", which is the distinction this note makes reliable; building it first would produce a verifier unable to tell its own outcomes apart. Comparable products also do not verify on save, so a blocking network call at save time would be an unexpected behavior rather than a missing one. + +## Acceptance criteria + +- The browser, both resolvers, and both credential reads accept and reject the same *provided* strings: whitespace-only, padded, interior-space, C0 control, emoji, CJK, and full-width inputs are refused; a printable-ASCII key is accepted, trimmed. +- A profile naming no credential still resolves to no key, and a route authenticating through the installed provider's own ambient discovery or OAuth keeps working untouched. +- A blank key field saves the rest of the card without writing a credential; a field holding only whitespace fails on the field instead of being silently dropped. +- A rejected key names the API key field in the web UI and blocks submit; nothing is written to settings or credentials. +- A key that reaches a resolver illegally fails as `INVALID_CREDENTIAL` with a message naming where to fix it, containing no part of the key, and is not retried. +- `llm-pi-ai` discovery reports an illegal key as a key fault, not as an unreachable endpoint. +- A legal key still travels the existing `credentials.set` path unchanged. + +## Risks + +The shape heuristic can refuse a real key. Upper-case-identifier-then-`=` and matched surrounding quotes are shapes no known provider issues, and the rule runs only in the browser, so a user who hits it can still set the credential through the environment. The residual cost is a confusing refusal for a key nobody has yet reported. + +Restricting to printable ASCII is stricter than the transport requires: a header value may carry `\x80`–`\xFF`. Admitting latin-1 would let `é` through to return an opaque 401 instead of a local, explained refusal, so the stricter rule is deliberate. A provider that issues latin-1 keys would need this rule widened. + +The charset predicate exists twice, once per source plane. The layout forbids sharing it, and the duplication gate may flag the pair; each side carries its own test and names its twin. + +The costliest way to get this wrong is to treat absence as invalidity. A rule applied to `undefined` would break every route authenticating through ambient discovery or OAuth — `openai-codex` cannot take a key at all — and a blank field that blocked submit would make editing any other setting demand re-entering the key. Both belong in the tests, not only in this note. + +Keys already stored by an earlier build are read through `resolveApiKey`, so an illegal stored value begins failing at resolution rather than at request time. That is the intent — the diagnosis improves — but it moves the failure earlier for anyone currently holding one. diff --git a/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.zh.md b/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.zh.md new file mode 100644 index 0000000000..28073660b1 --- /dev/null +++ b/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.zh.md @@ -0,0 +1,101 @@ +# Agent Note: 在 API Key 进入 HTTP header 之前校验其格式 + +Status: proposed + +[English](2026-08-06-api-key-format-validation.md) | 中文 + +## Problem + +一个含有 HTTP header value 无法承载的字符的 API Key,会被每一层配置界面接受,直到构造请求时才失败——离引发它的那个字段已经很远。 + +把含 emoji、中文或全角标点的 Key 粘进 Web 模型设置页,保存会报成功。第一轮对话随即失败于 `Cannot convert argument to a ByteString because the character at index 7 has a value of 55357 which is greater than 255`——其中的下标与码点是 UTF-16 内部细节,不附带任何可执行动作,却泄露了 Key 中某一个字符的码点。`llm-deepseek` 之所以产出这句,是因为 `fetch` 在 [adapter.ts](../../../../packages/llm/llm-deepseek/src/adapter.ts) 的 `try` 内部构造 `Bearer` header,而那个 `catch` 把一切失败都标为 `TRANSPORT`;该标签又在 `DEFAULT_RETRYABLE_CODES` 之中,于是一个永久且确定的故障还会被重试三次。 + +同样的输入在 `llm-pi-ai` 上更糟。它的探测路径在 [discovery.ts](../../../../packages/llm/llm-pi-ai/src/discovery.ts) 里用裸 `fetch` 构造同一个 header,并把一切失败包装成 `could not reach <url>`,于是一个本地的 Key 故障被报成网络不可达。这条探测在保存之前就够得着:`ProviderEditor` 把用户输入的 `keyDraft` 直接放进探测请求,所以「获取模型列表」按钮会在任何东西落盘之前就把非法 Key 发出去。 + +空白字符能通过每一道检查。`ProviderEditor` 判的是 `keyDraft.length`,`resolveAdapterOptions` 判的是 `config.apiKey.length`,于是三个空格构成的 Key 会被存下,随后以 `Bearer` 加若干空格去认证。`llm-pi-ai` 在 `resolveProfiles` 中拒绝空的字面量 `apiKey`,却对来自凭据或环境的 Key 完全不做检查——而那正是模型设置页写入的路径,也就是用户真正走的路径。 + +来源:deepseek-harness#1594 与 #1595;dsh-external#247、#249、#266、#210。 + +## Proposal + +一条规则定义什么是合法 Key:**trim 之后非空,且每个字符都落在 `[\x21-\x7E]`**——可打印 ASCII,不含空格。 + +这一个断言覆盖了来源列出的全部输入:空值、首尾空白、中间空白、C0 控制字符、emoji、中文、全角标点。它同时正是造成 ByteString 失败的那条约束,所以两个 issue 收敛于同一个定义,而不是两个恰好相关的修复。 + +第二条更窄的规则用于识别整行粘贴的环境变量:拒绝匹配 `^[A-Z][A-Z0-9_]*=` 或首尾成对引号的输入。把前缀限定为全大写可以让真实 Key 与之绝缘——`sk-` 这类形态会在连字符处中断标识符匹配。 + +### 不变量属于每一层,启发式属于人所在的那一层 + +字符集规则是不变量。非 ASCII 字符对任何 provider 都**不可能**在 header value 中传输,因此在浏览器、在各个 resolver、在每一次凭据读取上执行它,是结构上的一致而非约定上的一致。 + +形状规则是对人如何粘贴的猜测,因此**只在浏览器中运行**。`llm-pi-ai` 前面挂着 OpenAI、Anthropic 以及任意手工声明的网关,本仓库并不掌握它们的 Key 格式;若这条规则运行在 resolver 中,一个签发形如 `TENANT1=abc` 的网关会让用户被彻底锁死、无路可走——设置页拒绝它,手写的 `.env` 在读取时同样被拒。把启发式限制在粘贴动作发生的那一层,环境变量便始终是那条出路。 + +### 「没有 Key」是一种配置状态,不是缺失 + +在这里,「没有 API Key」意味着三件完全不同的事,其中只有一件是错误。规则作用于**已提供**的值;至于究竟有没有提供,由各个调用方自行判断。 + +**未指定。** 既不写 `apiKey` 也不写 `apiKeyEnv` 的 profile,是由 harness 所持有的 Key 之外的东西来鉴权的。[provider.ts](../../../../packages/llm/llm-pi-ai/src/provider.ts) 中的 `routeAuth` 保留内置 catalog provider 自身的鉴权,正是为了让 provider 原生的 ambient 发现得以存活;而该 catalog 附带的 `openai-codex` 通过 OAuth 鉴权,并会直接拒绝一个显式的 Key。`namesCredential` 的存在就是为了承载这一区分。在 `llm-deepseek` 中,缺省的 `apiKey` 同样会回落到 `apiKeyEnv`。未指定的情形永不参与校验。 + +**Web UI 中留空的输入框。** 即便某个 provider 的 Key 已经存好,该输入框也是空着打开的——`keyStored` 的文案写的是「已配置——输入新值以替换」——所以留空意味着*保持已存储的值*。`ProviderEditor` 在草稿为空时本就完全跳过 `credentials.set`,这一点保持不变:留空绝不能拦截提交,否则改一个 base URL 都得重新输一遍 Key。 + +**已提供,但为空或纯空白。** 这是唯一的错误,因为用户表达了设置 Key 的意图却什么都没给。`llm-pi-ai` 在 `resolveProfiles` 中的措辞本就是对的——*has an empty apiKey; omit it to use ambient authentication*——这种指明合法替代路径而非单纯拒绝的形态,正是其他界面要采用的。 + +因此 `normalizeApiKey` 接受 `string`,而绝非 `string | undefined`。 + +### 规则住在哪里 + +`normalizeApiKey` 是 `dsh-llm` seam 的新模块,与已经承担共享 header 事务的 [attribution.ts](../../../../packages/llm/llm/src/attribution.ts) 并列。两个适配器都依赖该 seam 且都需要这条规则,因此它拥有两个当前消费者而非一个预设消费者。它返回 trim 后的值,或一个原因(`empty`、`illegalCharacters`)。 + +客户端无法引入它:client 包只 reference client 包,因此 `packages/client/ui-models` 镜像这个断言并持有本地化文案,正如今天 `validateDeepSeekModels` 镜像 host 侧的 `catalogModel` schema。两侧在注释中互相指名。 + +### 各个界面各做什么 + +| 界面 | 改动 | +|---|---| +| `dsh-llm` | 新增 `normalizeApiKey`;新增 `INVALID_CREDENTIAL`,刻意不进 `DEFAULT_RETRYABLE_CODES`。 | +| `llm-deepseek` `resolveAdapterOptions` | 归一化已提供的 `apiKey`,与既有的超出 schema 的边界检查并排抛错;使用 trim 后的值。缺省的 `apiKey` 仍照旧回落到 `apiKeyEnv`。关闭 dsh-external#210。 | +| `llm-deepseek` `resolveApiKey` | 归一化凭据 seam 或环境返回的值;以 `INVALID_CREDENTIAL` 拒绝,消息指明模型设置页,绝不回显 Key。 | +| `llm-pi-ai` `resolveProfiles` | 把既有的空值检查扩展为这条共享规则,并保留其「omit it to use ambient authentication」的措辞。 | +| `llm-pi-ai` `resolveApiKey` | 归一化今天完全未受检的凭据与环境路径。不指定任何凭据的 profile 仍原样返回 `undefined`,ambient 与 OAuth 路由不受影响。 | +| `llm-pi-ai` `discoverModels` | 在构造 header 之前归一化,使非法 Key 不再被报成端点不可达。不带 Key 的探测照旧保持未鉴权。 | +| `ui-models` | 镜像字符集规则,加入形状启发式,在探测与 `credentials.set` 之前 trim `keyDraft`,并修正 `stringAt` 的空值判断。留空的输入框仍是可以提交的空操作;只含空白的输入框则以字段级失败呈现,使已输入的内容绝不被静默丢弃。按既有 `modelFailure` 的模式拦截提交并在字段上呈现失败。 | + +`ProviderEditor` 同时服务 DeepSeek 与 pi-ai 两种布局,因此一处客户端改动覆盖两个 provider。 + +`credentials-local` 刻意不动。它存储各类凭据,而可打印 ASCII 是 HTTP header 的约束而非凭据存储的约束;它既有的、拒绝任何 dotenv 样式都无法表示的值的行为保持原样。 + +## Alternatives considered + +**在 `apiKey` schema 字段上加 `.pattern()`。** vendor 中的 schemastery 支持它,且该 pattern 会随命名空间 schema 一同序列化到浏览器——一条规则,投递而非镜像。它落败于 pattern 无法先行 trim:那样 `cordis.yml` 会拒绝带首尾空白的 Key 而 `.env` 却容忍,resolver 与 schema 会对同一个字符串给出分歧。在 `resolveAdapterOptions` 中校验可以让每一层都是 trim-then-validate,而该函数本就是本包重新裁定 schema 无法表达的边界之处。 + +**由 client 与 host 共享一个校验模块。** 被 source plane 布局否决:client 包只 reference client 包外加 `vendor/cordis` 与 `support/invariants`,把它放宽到够得着 host 包会撞上这一分割本就要隔开的两份 `Context` 合并。在两侧各镜像一行断言并各配一份测试,是此处的既定形态。 + +**在适配器的 `catch` 中嗅探 `TypeError`。** 这只是事后归类 ByteString 失败,header 构造本身仍无防护。它依赖 Node 错误消息的措辞,因而会随运行时版本静默失效;它也帮不到 `llm-pi-ai`——后者的 header 构造在 pi-ai SDK 内部。在交出 Key 之前就拒绝,则对两个适配器与探测路径同时有效。 + +**在 `credentials-local.set` 中执行。** 它能一次性拦住所有写入方,包括手工编辑的文件。它落败于该 provider 存储各种类型的凭据,而一条源自 HTTP header 编码的规则并不属于它。 + +**让形状启发式也在 resolver 中运行。** 更对称,且能拦住直接写进 `.env` 的整行环境变量。因上文所述的锁死风险而否决:resolver 中的一次误判会让用户无路可走,浏览器中的一次误判则仍留有环境变量这条路。 + +**在保存时探测 provider 以证明 Key 可用。** 它能关掉来源真正开篇抱怨的那件事——保存报成功、第一轮才失败。因超出范围而否决,且在今天的代码上无法建成:对 pi-ai 恰好自带 catalog 的那些 provider,`discoverModels` 会在任何网络调用之前短路到内置 catalog,因而对 Key 什么都验证不了;而 DeepSeek 卡片根本没有探测。验证器的价值在于分清「Key 被拒」与「无法连通」,而这正是本 Agent Note 要让其变得可靠的区分;先建验证器只会得到一个分不清自身结果的验证器。同类产品也不在保存时验证,因此保存时的阻断式网络调用会是一个意外行为,而非一处缺失。 + +## Acceptance criteria + +- 浏览器、两个 resolver 与两处凭据读取接受与拒绝同一组**已提供**的字符串:纯空白、带首尾空白、含中间空格、C0 控制字符、emoji、中文、全角输入均被拒绝;可打印 ASCII 的 Key 被接受并 trim。 +- 不指定任何凭据的 profile 仍解析为「没有 Key」,通过内置 provider 自身的 ambient 发现或 OAuth 鉴权的路由原样可用。 +- 留空的 Key 输入框可以保存卡片其余部分而不写入凭据;只含空白的输入框则以字段级失败呈现,而不是被静默丢弃。 +- 被拒绝的 Key 在 Web UI 中定位到 API Key 字段并拦截提交;settings 与凭据均不写入。 +- 非法抵达 resolver 的 Key 以 `INVALID_CREDENTIAL` 失败,消息指明修复位置、不含 Key 的任何片段,且不被重试。 +- `llm-pi-ai` 的探测把非法 Key 报为 Key 故障,而非端点不可达。 +- 合法 Key 仍沿既有 `credentials.set` 路径原样通过。 + +## Risks + +形状启发式可能拒绝一个真实的 Key。全大写标识符接 `=`、以及首尾成对引号,都是已知 provider 不会签发的形态,且该规则只在浏览器中运行,因此撞上它的用户仍可通过环境变量设置该凭据。残留代价是对一个尚无人报告过的 Key 给出一次令人困惑的拒绝。 + +限定为可打印 ASCII 比传输本身的要求更严:header value 是可以承载 `\x80`–`\xFF` 的。放行 latin-1 会让 `é` 通过并换回一个语焉不详的 401,而不是一次本地的、有解释的拒绝,因此从严是刻意的。若某个 provider 签发 latin-1 的 Key,这条规则需要放宽。 + +字符集断言存在两份,每个 source plane 一份。布局禁止共享它,重复检测门禁可能会标记这一对;两侧各自带测试并在注释中指名其孪生体。 + +把这件事做错的最大代价,是把「未指定」当成「非法」。一条施加到 `undefined` 上的规则会打断每一条依赖 ambient 发现或 OAuth 鉴权的路由——`openai-codex` 根本无法接受 Key——而一个会拦截提交的空输入框,则会让改动任何其他设置都必须重新输入 Key。这两点都应落在测试里,而不只是写在本 Agent Note 中。 + +早先版本已存下的 Key 会经 `resolveApiKey` 读取,因此一个非法的既存值将从解析时开始失败,而非到请求时才失败。这正是意图所在——诊断变好了——但对当前正持有这类值的人而言,失败点提前了。 From 5b842895a8749f17224263b361ad3696200944cc Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Thu, 6 Aug 2026 20:43:50 +0800 Subject: [PATCH 32/67] feat(llm): define the legal API key shape in the seam --- packages/llm/llm/src/api-key.ts | 41 +++++++++++++++ packages/llm/llm/src/error.ts | 9 ++++ packages/llm/llm/src/index.ts | 34 ++++++++++++- packages/llm/llm/tests/api-key.spec.ts | 70 ++++++++++++++++++++++++++ 4 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 packages/llm/llm/src/api-key.ts create mode 100644 packages/llm/llm/tests/api-key.spec.ts diff --git a/packages/llm/llm/src/api-key.ts b/packages/llm/llm/src/api-key.ts new file mode 100644 index 0000000000..85d0b1ed60 --- /dev/null +++ b/packages/llm/llm/src/api-key.ts @@ -0,0 +1,41 @@ +/** + * The one definition of a well-formed provider API key, shared by every + * adapter that puts one in an HTTP header. + * @module @deepseek-ai/dsh-llm/api-key + */ + +/** + * Characters an HTTP header value carries verbatim and every known provider + * key uses: printable ASCII, space excluded. A key outside this set cannot + * reach any provider — `fetch` refuses to build the header — so this is a + * transport invariant rather than one provider's policy. Latin-1 is excluded + * deliberately: a header could carry it, but no provider issues it, and + * admitting it trades a local explained refusal for an opaque 401. + */ +const LEGAL_API_KEY = /^[\x21-\x7E]+$/ + +/** Why a supplied API key cannot be used. */ +export type ApiKeyRejection = 'empty' | 'illegalCharacters' + +/** The verdict on one supplied API key. */ +export type ApiKeyCheck = + | { readonly ok: true; readonly value: string } + | { readonly ok: false; readonly reason: ApiKeyRejection } + +/** + * Judge one *supplied* API key, trimming surrounding whitespace first. + * + * Trimming is silent because a padded key has one unambiguous reading; every + * other defect is reported. Absence is a configuration state this function + * never sees — a profile naming no credential authenticates through the + * provider's own ambient discovery or OAuth — so callers decide whether a + * value was supplied before asking. + * @param raw - the key exactly as configured, stored, or typed. + * @returns the trimmed key, or why it cannot be used. + */ +export function normalizeApiKey(raw: string): ApiKeyCheck { + const value = raw.trim() + if (value.length === 0) return { ok: false, reason: 'empty' } + if (!LEGAL_API_KEY.test(value)) return { ok: false, reason: 'illegalCharacters' } + return { ok: true, value } +} diff --git a/packages/llm/llm/src/error.ts b/packages/llm/llm/src/error.ts index fbb8bccca5..9ff193f1f8 100644 --- a/packages/llm/llm/src/error.ts +++ b/packages/llm/llm/src/error.ts @@ -38,6 +38,15 @@ export const QUOTA_EXCEEDED_CODE = 'QUOTA' */ export const EMPTY_RESPONSE_CODE = 'EMPTY_RESPONSE' +/** + * Canonical provider-neutral code for a credential that was supplied but + * cannot be used — malformed rather than absent. Distinct from + * `MISSING_CREDENTIAL` because the fix differs: correct the stored value + * rather than supply one. Deliberately outside the default retryable set — + * a malformed credential fails identically on every attempt. + */ +export const INVALID_CREDENTIAL_CODE = 'INVALID_CREDENTIAL' + /** Structured codes and plain phrases that explicitly name a context bound being exceeded. */ const STRUCTURED_CONTEXT_OVERFLOW = new RegExp( String.raw`(?:^|[^a-z0-9])context[\s_-](?:length|window)[\s_-]` diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 74ca171f64..287bfc2f34 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -25,13 +25,15 @@ import type { ResolvedRetryPolicy } from './retry-policy.ts' import type { ProviderRequestId } from './brand.ts' import { callConfigEquals, deepFreeze } from './call-config.ts' import type { LlmCallConfig, LlmCallConfigAdapterDefaults } from './call-config.ts' -import { HarnessError } from './error.ts' +import { HarnessError, INVALID_CREDENTIAL_CODE } from './error.ts' import { normalizeLlmFailure } from './adapter-failure.ts' +import { normalizeApiKey } from './api-key.ts' export * from './attribution.ts' export * from './brand.ts' export * from './never.ts' export * from './error.ts' +export * from './api-key.ts' export * from './types.ts' export * from './message.ts' export * from './retry-policy.ts' @@ -122,6 +124,36 @@ export class LlmError extends HarnessError { } } +/** + * Accept one supplied credential, or refuse it as unusable. + * + * A stored key arrives from the credentials seam, a `.env` line, or a shell + * export, all of which pick up surrounding whitespace, so trimming is silent. + * Anything else fails here rather than inside `fetch`, whose ByteString + * refusal names a UTF-16 code point instead of the setting to change. The key + * never enters the message: `ref` names where to fix it, and echoing any part + * of a secret into a log or a UI is the failure this diagnosis avoids. + * + * Lives beside {@link LlmError} rather than in `./api-key.ts` so the predicate + * module stays dependency-free; both adapters share this one diagnosis instead + * of keeping near-identical local copies. + * @param raw - the credential exactly as supplied. + * @param pkg - the refusing package name, prefixed to the diagnostic. + * @param ref - the credential reference the value resolved through. + * @returns the trimmed, usable key. + */ +export function assertUsableApiKey(raw: string, pkg: string, ref: string): string { + const checked = normalizeApiKey(raw) + if (checked.ok) return checked.value + throw new LlmError( + checked.reason === 'empty' + ? `${pkg}: the API key stored as ${ref} is blank; re-enter it on the web Models page` + : `${pkg}: the API key stored as ${ref} contains characters no HTTP header can carry;` + + ' re-enter it on the web Models page, pasting the raw key only', + INVALID_CREDENTIAL_CODE, + ) +} + /** One model call whose config and adapter registration were resolved together. */ export interface PreparedLlmCall { /** Detached, deep-frozen config with any adapter-owned default materialized. */ diff --git a/packages/llm/llm/tests/api-key.spec.ts b/packages/llm/llm/tests/api-key.spec.ts new file mode 100644 index 0000000000..a04a103fb9 --- /dev/null +++ b/packages/llm/llm/tests/api-key.spec.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { assertUsableApiKey, INVALID_CREDENTIAL_CODE, normalizeApiKey } from '@deepseek-ai/dsh-llm' + +describe('normalizeApiKey', () => { + it('accepts a printable-ASCII key unchanged', () => { + expect(normalizeApiKey('sk-0123456789abcdef')).toEqual({ ok: true, value: 'sk-0123456789abcdef' }) + }) + + it('trims surrounding whitespace before judging', () => { + expect(normalizeApiKey(' sk-abc\t\n')).toEqual({ ok: true, value: 'sk-abc' }) + }) + + it.each([ + ['an empty string', ''], + ['spaces only', ' '], + ['a tab only', '\t'], + ])('rejects %s as empty', (_label, raw) => { + expect(normalizeApiKey(raw)).toEqual({ ok: false, reason: 'empty' }) + }) + + it.each([ + ['an emoji', 'sk-\u{1F600}abc'], + ['CJK text', 'sk-你好'], + ['full-width punctuation', 'sk-abc,'], + ['an interior space', 'sk-abc def'], + ['a C0 control character', 'sk-abc\x01'], + ['a latin-1 character', 'sk-café'], + ])('rejects %s as illegal characters', (_label, raw) => { + expect(normalizeApiKey(raw)).toEqual({ ok: false, reason: 'illegalCharacters' }) + }) + + it('accepts the printable-ASCII boundary characters', () => { + expect(normalizeApiKey('!~')).toEqual({ ok: true, value: '!~' }) + }) + + it('publishes a code distinct from a missing credential', () => { + expect(INVALID_CREDENTIAL_CODE).toBe('INVALID_CREDENTIAL') + }) +}) + +describe('assertUsableApiKey', () => { + it('returns the trimmed key when it is usable', () => { + expect(assertUsableApiKey(' sk-abc ', 'llm-deepseek', 'DEEPSEEK_API_KEY')).toBe('sk-abc') + }) + + it('refuses a blank stored credential, naming the reference', () => { + expect(() => assertUsableApiKey(' ', 'llm-deepseek', 'DEEPSEEK_API_KEY')) + .toThrow(/llm-deepseek: the API key stored as DEEPSEEK_API_KEY is blank/) + }) + + it('refuses an unusable stored credential with the invalid-credential code', () => { + try { + assertUsableApiKey('sk-\u{1F600}', 'llm-pi-ai', 'ACME_API_KEY') + expect.fail('an illegal key must throw') + } catch (error) { + expect((error as { code: string }).code).toBe(INVALID_CREDENTIAL_CODE) + expect((error as Error).message).toContain('llm-pi-ai') + expect((error as Error).message).toContain('ACME_API_KEY') + } + }) + + it('never echoes the key it refuses', () => { + try { + assertUsableApiKey('sk-\u{1F600}supersecret', 'llm-deepseek', 'DEEPSEEK_API_KEY') + expect.fail('an illegal key must throw') + } catch (error) { + expect((error as Error).message).not.toContain('supersecret') + } + }) +}) From 88f5de57559d76fdcefd6d79621c466534c9996d Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Thu, 6 Aug 2026 20:57:30 +0800 Subject: [PATCH 33/67] docs: regenerate cordis catalog and event graph for shifted index.ts lines --- docs/cordis-catalog/events.md | 4 ++-- docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 44e0727101..b8eae843cc 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -493,7 +493,7 @@ The provider topology changed: an adapter registered or unregistered routes, or 'llm/adapters-updated'(): void ``` -Source: [`packages/llm/llm/src/index.ts:71`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:73`](../../packages/llm/llm/src/index.ts) ### `llm/stream` — waterfall @@ -517,7 +517,7 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t Types: [GenerateOptions](../core-data-structures/core.md) · [LlmService](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:60`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:62`](../../packages/llm/llm/src/index.ts) ## `session/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 896c17fb56..6b30d80751 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -941,7 +941,7 @@ stream(options: GenerateOptions): AsyncIterable<StreamChunk> Types: [AdapterRegistrationHandle](../core-data-structures/core.md) · [DirectoryRegistrationHandle](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmConfigurableProvider](../core-data-structures/core.md) · [LlmDiscoveredModel](../core-data-structures/core.md) · [LlmModelDiscoveryRequest](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [LlmResolvedModelInfo](../core-data-structures/core.md) · [PreparedLlmCall](../core-data-structures/llm-streaming.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:255`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:287`](../../packages/llm/llm/src/index.ts) ## `ctx.permission` — `PermissionService` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 3de02d1b4c..b2d8feaa1d 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -28,8 +28,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `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) | -| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:71`](../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:60`](../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) | +| `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-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | From b6b57ceda3c4b1a71b8741361b538699e2bcd2f3 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Thu, 6 Aug 2026 20:57:43 +0800 Subject: [PATCH 34/67] docs(llm): document the invalid-credential code --- packages/llm/llm/README.i18n.yaml | 4 ++-- packages/llm/llm/README.md | 5 +++++ packages/llm/llm/README.zh.md | 5 +++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 5e4daa179b..efdb8ea511 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/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/README.md -README.md: ca34ffdeaafdbe061e030c80997b7234ce36a1bd -README.zh.md: 1f95d3cd641126e129f94fe31269454a1bcce972 +README.md: 618d5f9f7c69c3ff2b420ae3fec96604802bf1be +README.zh.md: 4b99c477eae694d1315d90920e835fcfde3b571a diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index ca34ffdeaa..618d5f9f7c 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -63,6 +63,10 @@ Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta Every product adapter sends application identity on provider HTTP requests. `attributionHeaders(identity?)` builds the standard `User-Agent`, defaulting to public `APP_IDENTITY`; white-label deployments may replace but not suppress it. Adapters verify the wire header directly or through their library hook. See [the attribution Agent Note](../../../.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md). +### API key validation (`api-key.ts`) + +Every adapter that puts a credential in an HTTP header judges it the same way before use. `normalizeApiKey(raw)` trims surrounding whitespace, then accepts any non-empty printable-ASCII value (`/^[\x21-\x7E]+$/`, space excluded) or reports why not as an `ApiKeyRejection` (`'empty'` | `'illegalCharacters'`), both carried in the `ApiKeyCheck` result. Absence is never judged: a caller decides whether a value was supplied before asking, since a profile naming no credential authenticates through the provider's own ambient discovery or OAuth. + ### Classes - `LlmAdapter` — abstract base class for provider adapters. The only required method is `stream()`. @@ -73,6 +77,7 @@ Every product adapter sends application identity on provider HTTP requests. `att - `CONTEXT_WINDOW_EXCEEDED_CODE` — the provider-neutral code both DeepSeek adapters use when a request exceeds the model context window, regardless of thrown-HTTP versus in-band finish delivery. `isContextWindowExceededError(detail)` is their shared conservative classifier for OpenAI-compatible provider detail. - `QUOTA_EXCEEDED_CODE` — the non-transient provider-neutral code for exhausted account quota, balance, credits, budget, or usage limits. `isQuotaExceededError(detail)` keeps those failures distinct from request-rate limits. - `EMPTY_RESPONSE_CODE` — the provider-neutral code both adapters use for a degenerate provider completion: a terminal `stop` that carried no content blocks at all. Classified as an error finish (not a successful empty message) because the attempt produced nothing durable; `dsh-llm-retry` retries it by default. +- `INVALID_CREDENTIAL_CODE` — the provider-neutral code for a credential that was supplied but cannot be used: malformed rather than absent, so the fix is to correct the stored value rather than supply one — the distinction from `MISSING_CREDENTIAL`. Deliberately excluded from the default retryable set, since a malformed credential fails identically on every attempt. `assertUsableApiKey(raw, pkg, ref)` throws `LlmError` with this code, the one shared diagnosis every adapter uses for an unusable stored credential. ### Real adapters diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index 1f95d3cd64..4b99c477ea 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -63,6 +63,10 @@ 每个产品适配器都会在提供方 HTTP 请求上发送应用身份。`attributionHeaders(identity?)` 构建标准 `User-Agent`,默认为公开 `APP_IDENTITY`;白标部署可以替换它,但不能抑制它。适配器会直接验证 wire 标头,或通过自身库 hook 验证。详见 [归因 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md)。 +### API 密钥校验(`api-key.ts`) + +每个要把凭据放进 HTTP 标头的适配器,使用前都以同一套规则校验它。`normalizeApiKey(raw)` 先去除首尾空白,再接受任意非空的可打印 ASCII 值(`/^[\x21-\x7E]+$/`,不含空格),否则以 `ApiKeyRejection`(`'empty'` | `'illegalCharacters'`)说明拒绝原因,二者一并包含在 `ApiKeyCheck` 结果中。缺失从不参与校验:调用方会在询问之前自行判断是否提供了值——未点名凭据的 profile 会转由提供方自身的环境发现或 OAuth 完成认证。 + ### 类 - `LlmAdapter`:提供方适配器的抽象基类。唯一必需方法是 `stream()`。 @@ -73,6 +77,7 @@ - `CONTEXT_WINDOW_EXCEEDED_CODE`:当请求超过模型上下文窗口时,无论通过 HTTP 异常抛出还是带内 finish 交付,两个 DeepSeek 适配器都使用的提供方无关 code。`isContextWindowExceededError(detail)` 是它们针对 OpenAI 兼容提供方详细信息的共享保守分类器。 - `QUOTA_EXCEEDED_CODE`:帐户配额、余额、点数、预算或用量限制耗尽时使用的非短暂提供方无关 code。`isQuotaExceededError(detail)` 使这些失败与请求速率限制保持区分。 - `EMPTY_RESPONSE_CODE`:两个适配器都使用的提供方无关 code,用于表示退化的提供方生成结果:一个未携带任何内容块的终止 `stop`。它会被分类为错误 finish(而非成功空消息),因为尝试未产生持久内容;`dsh-llm-retry` 默认重试它。 +- `INVALID_CREDENTIAL_CODE`:已提供但无法使用的凭据所用的提供方无关 code——格式错误而非缺失,修复方式是改正已存储的值而非补供一个,这正是它与 `MISSING_CREDENTIAL` 的区别。它被刻意排除在默认可重试集合之外:格式错误的凭据每次尝试都会以同样方式失败。`assertUsableApiKey(raw, pkg, ref)` 会以该 code 抛出 `LlmError`,是每个适配器判定已存储凭据不可用时共用的诊断。 ### 真实适配器 From a48b84c001c885b2dc209bbcef2f6b87a03cc7c4 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Thu, 6 Aug 2026 21:14:39 +0800 Subject: [PATCH 35/67] fix(scripts): only publish images the repository owns, and keep their suffix Review found four real gaps in the image placement this PR introduced. Link rewriting only needs a target to exist, but publication copies its bytes onto the site: a reference reaching out of the tree through `../..` or a symlink would put a build-machine file on a published page. Only a regular file whose real path stays inside the repository is copied now, and anything else fails the projection naming the page and the target. A placed reference kept none of its `?query` or `#fragment`, which the GitHub branch has always carried and which decides what an SVG view fragment or a Vite query means. The suffix rides along again, and the file name is percent-encoded because the destination is a Markdown inline target. Page outputs and placed images now claim projected paths from one map, so the "fail loud rather than overwrite" invariant covers a page and an image landing on one path, not only two images. `docsSourceFiles()` reports placed images, so replacing a screenshot re-projects under `docs:dev` instead of serving the previous copy until something touches the page. The guide said to set `agent-loop`'s `agents` to change the default model, which does nothing for `dsh web`: that default is `api-gateway`'s, and the shipped composition leaves `agents` empty. It also promised that a catalog provider needs only an API key, which is false for Bedrock, Vertex, Azure, and Codex. Both are corrected. The projection note and the doc-site skill carried the superseded "a repository image becomes a raw GitHub URL" rule; both now describe what ships. --- ...13-documentation-site-projection.i18n.yaml | 4 +- ...026-07-13-documentation-site-projection.md | 2 +- ...-07-13-documentation-site-projection.zh.md | 2 +- ...8-06-doc-site-carries-its-images.i18n.yaml | 4 +- .../2026-08-06-doc-site-carries-its-images.md | 8 +- ...26-08-06-doc-site-carries-its-images.zh.md | 8 +- .agents/skills/dsh-doc-site-sync/SKILL.md | 1 + docs/user/guide/providers.i18n.yaml | 4 +- docs/user/guide/providers.md | 17 +-- docs/user/guide/providers.zh.md | 17 +-- scripts/project-doc-site.spec.ts | 56 +++++++++- scripts/project-doc-site.ts | 105 ++++++++++++++---- 12 files changed, 178 insertions(+), 50 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml index 74ded5f605..7fa4d3fbba 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-13-documentation-site-projection.md -2026-07-13-documentation-site-projection.md: 2452c9dfa53e05061446df2fe650f3b4d6428c01 -2026-07-13-documentation-site-projection.zh.md: 6f1c79ac502a04714cd77f680108dbff035b048c +2026-07-13-documentation-site-projection.md: f19d9b309aa22821a75086dc07ee302097631ba0 +2026-07-13-documentation-site-projection.zh.md: cc5e94e709f0639fd35ad81165b199cc5c9effc0 diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md index 2452c9dfa5..f19d9b309a 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md @@ -18,7 +18,7 @@ Canonical Markdown remains in the repository tier that owns it. Product-facing g Locale home projections retain only the canonical YAML frontmatter. The repository-facing body can keep its H1 and bilingual source links, while the VitePress home theme owns the rendered hero and features and the site navigation owns locale switching. -The projector parses Markdown links without reserializing the document. A link to another published source becomes a site-relative route; a link to an unpublished repository file becomes a GitHub source link; a repository image becomes a raw GitHub URL. Missing relative targets fail projection. Unit tests pin these transformations, and `docs:check` runs the projector tests plus a production VitePress build as part of `doc-sync` and the parallel documentation gates. +The projector parses Markdown links without reserializing the document. A link to another published source becomes a site-relative route; a link to an unpublished repository file becomes a GitHub source link; a repository image is copied into the generated tree and referenced from there ([why](2026-08-06-doc-site-carries-its-images.md)). Missing relative targets fail projection. Unit tests pin these transformations, and `docs:check` runs the projector tests plus a production VitePress build as part of `doc-sync` and the parallel documentation gates. `website/AGENTS.md` is the only maintained Markdown file in the website subtree. The projector test enumerates tracked and unignored files and rejects any other website Markdown, so site-specific locale, route, API, or generated source copies cannot bypass the publication manifest. diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md index 6f1c79ac50..cc5e94e709 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md @@ -18,7 +18,7 @@ Status: implemented 各 locale 的首页投影只保留权威 YAML frontmatter。面向仓库的正文可以保留其 H1 和双语源文件链接,而 VitePress 首页主题负责渲染 hero 与功能区,网站导航负责切换 locale。 -投影器解析 Markdown 链接,但不会重新序列化文档。指向另一个已发布源文件的链接会变成站内相对路由;指向未发布仓库文件的链接会变成 GitHub 源文件链接;仓库图片会变成 GitHub raw URL。相对目标不存在时,投影会失败。单元测试会锁定这些转换行为,`docs:check` 则运行投影器测试和 VitePress 生产构建,并将二者纳入 `doc-sync` 和并行文档门禁。 +投影器解析 Markdown 链接,但不会重新序列化文档。指向另一个已发布源文件的链接会变成站内相对路由;指向未发布仓库文件的链接会变成 GitHub 源文件链接;仓库图片会被拷贝进生成树并从那里引用([原因](2026-08-06-doc-site-carries-its-images.md))。相对目标不存在时,投影会失败。单元测试会锁定这些转换行为,`docs:check` 则运行投影器测试和 VitePress 生产构建,并将二者纳入 `doc-sync` 和并行文档门禁。 `website/AGENTS.md` 是网站子树中唯一维护的 Markdown 文件。投影器测试会枚举所有已跟踪文件和未被忽略的未跟踪文件,并拒绝网站中的任何其他 Markdown,因此网站专用的 locale、路由、API 或生成源文件副本无法绕过发布 manifest。 diff --git a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.i18n.yaml b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.i18n.yaml index 75018c8374..32b51699e2 100644 --- a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-06-doc-site-carries-its-images.md -2026-08-06-doc-site-carries-its-images.md: 21593c2cadb6b2aaf52350ab61156ad892bc4163 -2026-08-06-doc-site-carries-its-images.zh.md: 54aee878a9d0f16d1fe3b219da7b248fb5148fa3 +2026-08-06-doc-site-carries-its-images.md: 9109808874579b79d85c2e22b0987110f41ddc42 +2026-08-06-doc-site-carries-its-images.zh.md: d601112e8870150c363d8533e85ef86e7f3f8ffc diff --git a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.md b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.md index 21593c2cad..9109808874 100644 --- a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.md +++ b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.md @@ -14,7 +14,11 @@ That works only for a public repository. This one is private, and `raw.githubuse `rewriteMarkdown` takes an optional `placeImage(absPath): string`. When a page references an image the manifest does not publish as a page, the projector copies that file into the generated tree beside the page and rewrites the reference to `./<basename>`; Vite then bundles it like any other site asset. Nothing about repository visibility can reach the published page. -The copy lands beside the page rather than in a shared asset directory. Each locale's route tree gets its own copy, so one relative URL is correct from both `guide/` and `en/guide/` without computing per-locale prefixes, and a page's assets are removed with the page when the manifest drops it. Two sources that would project onto one path throw, in the same spirit as the existing duplicate-route check, rather than letting whichever copied last win. +The copy lands beside the page rather than in a shared asset directory. Each locale's route tree gets its own copy, so one relative URL is correct from both `guide/` and `en/guide/` without computing per-locale prefixes, and a page's assets are removed with the page when the manifest drops it. One map claims every projected path — pages and images alike — so a second source for one path throws, in the same spirit as the existing duplicate-route check, rather than letting whichever wrote last win. + +Only a regular file whose real path stays inside the repository is copied; anything else fails the projection naming the page and the target. Link rewriting needs to know a target *exists*, but publication copies its bytes onto the site, so a reference escaping the repository — through `../..` or a symlink out of the tree — would put a build-machine file on a published page. The reference's `?query` or `#fragment` rides along to the placed URL exactly as the GitHub branch has always carried it, and the file name is percent-encoded because the destination is a Markdown inline target. + +`docsSourceFiles()` reports the placed images alongside the Markdown, so the dev server's watcher re-projects when a screenshot is replaced instead of serving the previous copy until something touches the page. `placeImage` is optional because `rewriteMarkdown` is also called directly by its spec, where no generated tree exists. Without it the old GitHub-raw behavior stands, which keeps that seam honest: the fallback is still the correct answer for a consumer that only rewrites text. @@ -36,4 +40,4 @@ Images referenced from *unpublished* documents are untouched: they still resolve ## Testing -`scripts/project-doc-site.spec.ts` covers the placer receiving the resolved absolute path and the returned URL landing in the Markdown, a published page link still resolving to its route when a placer is present, and the unchanged GitHub-raw fallback when no placer is supplied. `pnpm docs:check` builds the site with the model-provider guide's screenshots and fails on a missing source; the copied files and their `./<basename>` references were verified in `website/.generated` and in a running `docs:dev` (`naturalWidth > 0` in both locales). +`scripts/project-doc-site.spec.ts` covers the placer receiving the resolved absolute path and the returned URL landing in the Markdown, a placed reference keeping its fragment, a published page link still resolving to its route when a placer is present, and the unchanged GitHub-raw fallback when no placer is supplied. `publishableImage` is covered directly: a regular file inside the repository resolves, while a symlink whose target escapes it, a path outside it, and a directory are all refused. `pnpm docs:check` builds the site with the model-provider guide's screenshots and fails on a missing source; the copied files and their `./<basename>` references were verified in `website/.generated` and in a running `docs:dev` (`naturalWidth > 0` in both locales). diff --git a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.zh.md b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.zh.md index 54aee878a9..d601112e88 100644 --- a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.zh.md +++ b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.zh.md @@ -14,7 +14,11 @@ Status: implemented `rewriteMarkdown` 新增可选的 `placeImage(absPath): string`。当页面引用了一张清单未作为页面发布的图片时,投影把该文件复制进生成树中该页面的旁边,并把引用改写为 `./<basename>`;随后 Vite 会像处理其他站点资源一样打包它。仓库可见性再也影响不到已发布页面。 -副本落在页面旁边,而不是某个共享资源目录。每个 locale 的路由树各持一份副本,因此同一个相对 URL 在 `guide/` 与 `en/guide/` 下都正确,无需按 locale 计算前缀;清单撤下某页时,它的资源也随之消失。两个来源若会投影到同一路径则抛错——与既有的重复路由检查同一个立场——而不是让最后拷贝的那个静默胜出。 +副本落在页面旁边,而不是某个共享资源目录。每个 locale 的路由树各持一份副本,因此同一个相对 URL 在 `guide/` 与 `en/guide/` 下都正确,无需按 locale 计算前缀;清单撤下某页时,它的资源也随之消失。一张表登记所有被投影的路径——页面与图片一视同仁——同一路径出现第二个来源就抛错,与既有的重复路由检查同一个立场,而不是让最后写入的那个静默胜出。 + +只有真实路径位于仓库内的普通文件才会被拷贝,其余一律让投影失败并点名页面与目标。链接改写只需要知道目标**存在**,但发布是把它的字节拷上站点,因此一个逃出仓库的引用——经由 `../..` 或指向树外的符号链接——会把构建机上的文件放到已发布页面上。引用自带的 `?query` 或 `#fragment` 会随安置后的 URL 一同保留,与 GitHub 分支一贯的做法一致;文件名做百分号编码,因为目标位于 Markdown 内联目标的位置。 + +`docsSourceFiles()` 会连同被安置的图片一起上报,于是替换截图时开发服务器的 watcher 会重新投影,而不是一直服务旧副本直到有人碰一下页面。 `placeImage` 之所以可选,是因为 `rewriteMarkdown` 也被它自己的 spec 直接调用,而那里并不存在生成树。不传它时保持原有的 GitHub raw 行为,这也让该接缝保持诚实:对只改写文本的消费方而言,这个回退仍是正确答案。 @@ -36,4 +40,4 @@ Status: implemented ## Testing -`scripts/project-doc-site.spec.ts` 覆盖:placer 收到解析后的绝对路径且其返回的 URL 落进 Markdown、存在 placer 时已发布页面的链接仍解析到自己的路由、以及不传 placer 时不变的 GitHub raw 回退。`pnpm docs:check` 会带着配置模型指南的截图构建站点,并在来源缺失时失败;被拷贝的文件及其 `./<basename>` 引用已在 `website/.generated` 与运行中的 `docs:dev` 里核实(两个 locale 均 `naturalWidth > 0`)。 +`scripts/project-doc-site.spec.ts` 覆盖:placer 收到解析后的绝对路径且其返回的 URL 落进 Markdown、被安置的引用保留其 fragment、存在 placer 时已发布页面的链接仍解析到自己的路由、以及不传 placer 时不变的 GitHub raw 回退。`publishableImage` 另有直接覆盖:仓库内的普通文件被接受,而目标逃出仓库的符号链接、仓库外的路径与目录一律拒绝。`pnpm docs:check` 会带着配置模型指南的截图构建站点,并在来源缺失时失败;被拷贝的文件及其 `./<basename>` 引用已在 `website/.generated` 与运行中的 `docs:dev` 里核实(两个 locale 均 `naturalWidth > 0`)。 diff --git a/.agents/skills/dsh-doc-site-sync/SKILL.md b/.agents/skills/dsh-doc-site-sync/SKILL.md index 4d88d3f04f..3f93a6560a 100644 --- a/.agents/skills/dsh-doc-site-sync/SKILL.md +++ b/.agents/skills/dsh-doc-site-sync/SKILL.md @@ -46,6 +46,7 @@ Write normal repository-relative Markdown links in canonical docs. The projector - A target present in the manifest becomes a site-relative route. - An existing target outside the manifest becomes a GitHub source link, including supported line suffixes. +- An image is the exception: its file is copied into the generated tree and referenced from there, so the site serves it regardless of repository visibility. It must be a regular file inside the repository. - External URLs, site-absolute URLs, email links, and fragment-only links remain unchanged. - A missing repository-relative target fails projection instead of silently producing a broken link. diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml index 324bcfb5c3..665eae8457 100644 --- a/docs/user/guide/providers.i18n.yaml +++ b/docs/user/guide/providers.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/providers.md -providers.md: d96cab0fa09583d81d98863169819fdd78d636e7 -providers.zh.md: d413fec2f9d703e31e82e50fcbe83b24bd58ee39 +providers.md: 66b6cf25c61a252fbd10a85f8c79c246eeae8abe +providers.zh.md: a2c33c90be971e09ab29e2355ca6a7ae6f947c39 diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md index d96cab0fa0..66b6cf25c6 100644 --- a/docs/user/guide/providers.md +++ b/docs/user/guide/providers.md @@ -23,6 +23,8 @@ Start `pnpm run dsh web` and open **Settings → Models**. **Add a provider from the installed catalog.** Choose **Add provider**, pick one of pi-ai's catalog providers (anthropic, openai, and so on), and enter that provider's API key. The endpoint, protocol, and model catalog all come from the catalog; the key is the only thing you owe. +That holds for providers that authenticate with an API key. The catalog also carries Bedrock, Vertex, Azure, and Codex, which need AWS credentials and a region, an ADC project, an `api-version`, and OAuth respectively: filling in the key field alone will not make them work. Those authenticate through pi-ai's own environment discovery, with credentials prepared the way each one requires. + **Add a custom provider.** Choose **Add a custom provider** for a route the catalog does not ship — a company gateway, a self-hosted server, or a provider newer than the installed catalog. It asks for a Provider ID (the lowercase identifier that names the route in requests and as its credential), a base URL, a protocol, and at least one model. ![The custom provider form: Provider ID, display name, base URL, API protocol, and API key](providers-custom-form.png) @@ -93,18 +95,19 @@ References resolve from `$DSH_HOME/.env` — what the Models page's key fields w ## Point an agent at the new provider -A configured route appears in the web model picker and can be switched at any time. To change the default, edit the `agent-loop` entry's `provider` and `model` in `cordis.yml`: +A configured route appears in the web model picker and can be switched at any time, which is how most people use it. + +A new session's default model comes from the `api-gateway` entry (`@deepseek-ai/dsh-host-apiproxy`) and its `provider` and `model`, which ship as `deepseek-official` and `deepseek-v4-flash`. To change that default, override the entry in `$DSH_HOME/config.yaml`: ```yaml -- id: agent-loop - name: '@deepseek-ai/dsh-agent-loop' +- id: api-gateway config: - agents: - - id: main - provider: acme-gateway - model: acme-large + provider: acme-gateway + model: acme-large ``` +A patch replaces that entry's whole `config`, so write out every key it needs to keep. A composition you assemble yourself — headless, for instance — sets `agent-loop`'s `agents` instead. + ## Troubleshooting - **`MISSING_CREDENTIAL`** — the variable the profile's `apiKeyEnv` names holds no value. Store the key once through the Models page, or export the variable. diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md index d413fec2f9..a2c33c90be 100644 --- a/docs/user/guide/providers.zh.md +++ b/docs/user/guide/providers.zh.md @@ -23,6 +23,8 @@ Harness 出厂就带 DeepSeek,同时挂着一个通用的多提供方适配器 **添加内置目录里的提供方。** 点**添加提供方**,从 pi-ai 内置目录中选一个(anthropic、openai 等),填入该提供方的 API 密钥。端点、协议和模型目录都由内置目录提供,你只需要给密钥。 +只对以 API 密钥认证的提供方成立。目录里也有 Bedrock、Vertex、Azure、Codex:它们分别需要 AWS 凭据与区域、ADC 项目配置、`api-version`、OAuth,只填密钥框不会让它们工作——这类提供方靠 pi-ai 自己的环境发现认证,凭据按各自的原生方式准备。 + **添加自定义提供方。** 点**添加自定义提供方**,用于内置目录没有的路由——公司网关、自建服务,或比内置目录更新的提供方。需要填 Provider ID(请求里点名它、也作为凭据名的小写标识)、API 地址、协议,以及至少一个模型。 ![自定义提供方表单:Provider ID、显示名称、API 地址、API 协议、API 密钥](providers-custom-form.zh.png) @@ -93,18 +95,19 @@ settings 段落**逐个提供方**地盖在 `cordis.yml` 的同名配置之上 ## 让 agent 用上新提供方 -配好的路由会出现在 Web 的模型选择器里,随时可切。要改默认值,就在 `cordis.yml` 里改 `agent-loop` 那条的 `provider` 与 `model`: +配好的路由会出现在 Web 的模型选择器里,随时可切,这也是最常用的方式。 + +新会话的默认模型来自 `api-gateway` 那条(`@deepseek-ai/dsh-host-apiproxy`)的 `provider` 与 `model`,出厂值是 `deepseek-official` 与 `deepseek-v4-flash`。要改默认值,就在 `$DSH_HOME/config.yaml` 里覆盖该条: ```yaml -- id: agent-loop - name: '@deepseek-ai/dsh-agent-loop' +- id: api-gateway config: - agents: - - id: main - provider: acme-gateway - model: acme-large + provider: acme-gateway + model: acme-large ``` +补丁会整体替换该条的 `config`,所以要把这条需要保留的键一并写出。自行组装的 `cordis.yml`(例如 headless)改的则是 `agent-loop` 的 `agents`。 + ## 排错 - **`MISSING_CREDENTIAL`** — profile 里的 `apiKeyEnv` 指向的变量没有值。用模型页存一次密钥,或导出该环境变量。 diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index c6402d7fc8..6770381526 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -1,12 +1,14 @@ /** Tests for the documentation website projection adapter. */ import { execFileSync } from 'node:child_process' -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { docsPages, type DocsPage } from '../website/docs.ts' -import { addProjectionFrontmatter, projectedPageContent, rewriteMarkdown } from './project-doc-site.ts' +import { + addProjectionFrontmatter, projectedPageContent, publishableImage, rewriteMarkdown, +} from './project-doc-site.ts' const roots: string[] = [] const repositoryRoot = resolve(import.meta.dirname, '..') @@ -63,6 +65,32 @@ describe('website source layout', () => { }) }) +describe('publishableImage', () => { + it('accepts a regular file inside the repository', () => { + const { root } = fixture() + const real = realpathSync(join(root, 'packages/logo.svg')) + expect(publishableImage(join(root, 'packages/logo.svg'), realpathSync(root))).toBe(real) + }) + + it('refuses a target whose real path escapes the repository', () => { + // Publication copies the bytes onto the site, so a reference reaching a + // build-machine file must not be treated as an image the repository owns. + const { root } = fixture() + const outside = mkdtempSync(join(tmpdir(), 'dsh-doc-site-outside-')) + roots.push(outside) + writeFileSync(join(outside, 'secret.png'), 'not really a png\n') + symlinkSync(join(outside, 'secret.png'), join(root, 'packages/linked.png')) + + expect(publishableImage(join(root, 'packages/linked.png'), realpathSync(root))).toBeUndefined() + expect(publishableImage(join(outside, 'secret.png'), realpathSync(root))).toBeUndefined() + }) + + it('refuses a directory', () => { + const { root } = fixture() + expect(publishableImage(join(root, 'packages'), realpathSync(root))).toBeUndefined() + }) +}) + describe('rewriteMarkdown', () => { it('maps published pages and pins unpublished source links', () => { const { root, pages } = fixture() @@ -107,7 +135,9 @@ describe('rewriteMarkdown', () => { it('hands an image to the placer and uses the URL it returns', () => { // A raw GitHub URL cannot serve a private repository, so the site build - // carries images itself; the placer is what puts them there. + // carries images itself; the placer is what puts them there. The stand-in + // derives its URL the way the real one does, so a placer that stopped + // returning the basename would fail here rather than pass on a constant. const { root, pages } = fixture() const placed: string[] = [] expect(rewriteMarkdown('![logo](../packages/logo.svg)\n', { @@ -118,13 +148,29 @@ describe('rewriteMarkdown', () => { repoRoot: root, repositoryRef: 'abc123', placeImage: (absPath) => { - placed.push(absPath.split('/').pop() ?? '') - return './logo.svg' + const name = absPath.split('/').pop() ?? '' + placed.push(name) + return `./${name}` }, })).toBe('![logo](./logo.svg)\n') expect(placed).toEqual(['logo.svg']) }) + it('keeps a placed image\u2019s query or fragment', () => { + // An SVG view fragment and a Vite query both change what the reference + // means, and the GitHub branch has always carried them. + const { root, pages } = fixture() + expect(rewriteMarkdown('![logo](../packages/logo.svg#view)\n', { + locale: 'en', + sourcePath: 'docs/a.md', + route: 'en/a.md', + pages, + repoRoot: root, + repositoryRef: 'abc123', + placeImage: absPath => `./${absPath.split('/').pop() ?? ''}`, + })).toBe('![logo](./logo.svg#view)\n') + }) + it('leaves a published page link to the route even when a placer exists', () => { const { root, pages } = fixture() expect(rewriteMarkdown('[B](b.md)\n', { diff --git a/scripts/project-doc-site.ts b/scripts/project-doc-site.ts index ef821bd00e..02a64b023a 100644 --- a/scripts/project-doc-site.ts +++ b/scripts/project-doc-site.ts @@ -5,7 +5,9 @@ * tier, while this adapter rewrites cross-source links for the public site. */ -import { copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { + copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync, +} from 'node:fs' import { basename, dirname, extname, posix, relative, resolve, sep } from 'node:path' import { fromMarkdown } from 'mdast-util-from-markdown' import { gfmFromMarkdown } from 'mdast-util-gfm' @@ -234,7 +236,9 @@ export function rewriteMarkdown(source: string, options: RewriteMarkdownOptions) const nextUrl = page !== undefined ? routeTarget(options.route, page.route, suffix) : node.type === 'image' && options.placeImage !== undefined - ? options.placeImage(absPath) + // The suffix rides along exactly as the GitHub branch keeps it: an SVG + // view fragment or a Vite query changes what the reference means. + ? `${options.placeImage(absPath)}${suffix}` : githubTarget(absPath, line, suffix, options.repositoryRef, options.repoRoot, node.type === 'image') const start = node.position?.start.offset @@ -302,19 +306,78 @@ export function projectedPageContent(markdown: string, page: DocsPage): string { return markdown.slice(0, closing + closingDelimiter.length) } -/** Canonical Markdown files watched by the local VitePress dev server. */ +/** + * The repository file one image reference resolves to, or `undefined` when the + * target is not a local file this build may publish. + * @param absPath - resolved image target. + * @param repoRoot - repository root every published image must stay inside. + * @returns the file's real path, or `undefined` when it must not be copied. + * + * Only a regular file whose real path stays inside the repository qualifies. + * Publication copies the bytes into the site, so a reference escaping the + * repository — `../../.ssh/id_rsa`, or a symlink pointing out of the tree — + * would put a build-machine file on the site; `existsSync` alone, which is all + * link resolution needs, does not answer that. + */ +export function publishableImage(absPath: string, repoRoot: string): string | undefined { + const real = realpathSync(absPath) + const inside = real === repoRoot || real.startsWith(`${repoRoot}${sep}`) + return inside && statSync(real).isFile() ? real : undefined +} + +/** Every local image a published page references, resolved to its repository file. */ +function referencedImages(): string[] { + const found = new Set<string>() + for (const page of docsPages) { + const sourceAbs = resolve(root, page.source) + if (!existsSync(sourceAbs)) continue + rewriteMarkdown(readFileSync(sourceAbs, 'utf8'), { + sourcePath: page.source, + locale: page.locale, + route: page.route, + pages: docsPages, + repoRoot: root, + repositoryRef: 'master', + placeImage: (absPath) => { + const real = publishableImage(absPath, root) + if (real !== undefined) found.add(real) + return '' + }, + }) + } + return [...found] +} + +/** + * Files watched by the local VitePress dev server: every canonical Markdown + * source, plus the images they publish. Without the images, replacing a + * screenshot leaves the previous copy in the generated tree until something + * touches the Markdown beside it. + */ export function docsSourceFiles(): string[] { - return [...new Set(docsPages.map(page => resolve(root, page.source)))] + return [...new Set([...docsPages.map(page => resolve(root, page.source)), ...referencedImages()])] } /** Rebuild the disposable VitePress source tree from the publication manifest. */ export function projectDocs(): void { const routes = new Set<string>() - /** Projected asset path to the source it came from, for collision detection. */ - const assets = new Map<string, string>() + /** Projected path to the repository file that claimed it, pages and images alike. */ + const claimed = new Map<string, string>() const repositoryRef = process.env.GITHUB_SHA ?? 'master' rmSync(generatedRoot, { recursive: true, force: true }) + /** Reserve one projected path, refusing a second source for it. */ + const claim = (target: string, sourceAbs: string): void => { + const holder = claimed.get(target) + if (holder !== undefined && holder !== sourceAbs) { + throw new Error( + `project-doc-site: ${repoPath(sourceAbs, root)} and ${repoPath(holder, root)}` + + ` both project to ${relative(generatedRoot, target).split(sep).join('/')}.`, + ) + } + claimed.set(target, sourceAbs) + } + for (const page of docsPages) { if (routes.has(page.route)) throw new Error(`project-doc-site: duplicate route ${JSON.stringify(page.route)}.`) routes.add(page.route) @@ -323,6 +386,9 @@ export function projectDocs(): void { throw new Error(`project-doc-site: source ${JSON.stringify(page.source)} does not exist or is not a file.`) } const output = resolve(generatedRoot, page.route) + // Claimed before the images are placed: a page and an image landing on one + // path would otherwise overwrite each other in whichever order they ran. + claim(output, sourceAbs) mkdirSync(dirname(output), { recursive: true }) const markdown = readFileSync(sourceAbs, 'utf8') const projected = rewriteMarkdown(markdown, { @@ -333,22 +399,23 @@ export function projectDocs(): void { repoRoot: root, repositoryRef, placeImage: (absPath) => { - // Beside the page that references it, under its own basename: each - // locale's route tree gets its own copy, so one relative URL is correct - // from both. Two sources that would land on one name are a collision - // rather than a silent overwrite of whichever copied last. - const name = basename(absPath) - const target = resolve(dirname(output), name) - const claimed = assets.get(target) - if (claimed !== undefined && claimed !== absPath) { + const real = publishableImage(absPath, root) + if (real === undefined) { throw new Error( - `project-doc-site: ${repoPath(absPath, root)} and ${repoPath(claimed, root)}` - + ` both project to ${relative(generatedRoot, target).split(sep).join('/')}.`, + `project-doc-site: ${page.source} references image ${repoPath(absPath, root)},` + + ' which is not a regular file inside the repository.', ) } - assets.set(target, absPath) - copyFileSync(absPath, target) - return `./${name}` + // Beside the page that references it, under its own basename: each + // locale's route tree gets its own copy, so one relative URL is correct + // from both. + const name = basename(real) + const target = resolve(dirname(output), name) + claim(target, real) + copyFileSync(real, target) + // Encoded because the destination is a Markdown inline target, where an + // unescaped space would end it early. + return `./${encodeURI(name)}` }, }) writeFileSync(output, addProjectionFrontmatter(projectedPageContent(projected, page), page)) 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 36/67] 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 37/67] 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 5514dd2bd6409d076bfa963a1c835fdd97e3f61b Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Thu, 6 Aug 2026 21:39:57 +0800 Subject: [PATCH 38/67] fix(llm-deepseek): refuse an API key no header can carry --- docs/config-catalog.md | 6 +++- packages/llm/llm-deepseek/src/index.ts | 27 +++++++++++++---- .../llm/llm-deepseek/tests/adapter.spec.ts | 30 +++++++++++++++++++ .../llm-deepseek/tests/dynamic-config.spec.ts | 21 ++++++++++++- 4 files changed, 77 insertions(+), 7 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 89f1529387..a3e63d994d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -642,7 +642,11 @@ Requires: `llm` * reasoning effort resolves to `high`. */ export interface Config { - /** Literal API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */ + /** + * Literal API key; prefer {@link apiKeyEnv} so no secret enters configuration files. Trimmed + * and format-checked by {@link resolveAdapterOptions}; a value no HTTP header can carry fails + * there rather than inside `fetch`. + */ apiKey?: string /** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */ apiKeyEnv?: string diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index cd2bb9a24e..6aaab15573 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -13,7 +13,7 @@ import type { Context } from 'cordis' import z from 'schemastery' -import { LlmError, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' +import { assertUsableApiKey, LlmError, normalizeApiKey, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm' import { credentialRef } from '@deepseek-ai/dsh-credentials' import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' @@ -58,7 +58,11 @@ const DEFAULT_MODELS: DeepSeekCatalogModel[] = [ * reasoning effort resolves to `high`. */ export interface Config { - /** Literal API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */ + /** + * Literal API key; prefer {@link apiKeyEnv} so no secret enters configuration files. Trimmed + * and format-checked by {@link resolveAdapterOptions}; a value no HTTP header can carry fails + * there rather than inside `fetch`. + */ apiKey?: string /** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */ apiKeyEnv?: string @@ -174,8 +178,21 @@ export function resolveAdapterOptions(config: Config): ResolvedDeepSeekOptions { `llm-deepseek: streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`, ) } + // An absent apiKey is not a failure: it falls through to apiKeyEnv below. + // A supplied one must be usable, so a malformed literal fails here beside + // the other beyond-schema bounds instead of inside `fetch`. + let apiKey: string | undefined + if (config.apiKey !== undefined) { + const checked = normalizeApiKey(config.apiKey) + if (!checked.ok) { + throw new Error(checked.reason === 'empty' + ? 'llm-deepseek: apiKey is empty; omit it to resolve the key from apiKeyEnv' + : 'llm-deepseek: apiKey contains characters no HTTP header can carry; paste the raw key only') + } + apiKey = checked.value + } return { - ...config.apiKey !== undefined && config.apiKey.length > 0 ? { apiKey: config.apiKey } : {}, + ...apiKey === undefined ? {} : { apiKey }, apiKeyEnv: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV), baseURL: config.baseURL ?? process.env.DEEPSEEK_BASE_URL ?? PUBLIC_BASE_URL, defaults: { @@ -223,12 +240,12 @@ export function apply(ctx: Context, config: Config): void { const credentials = ctx.get('credentials') if (credentials !== undefined) { const hit = await credentials.resolve(ref) - if (hit !== undefined) return hit.value + if (hit !== undefined) return assertUsableApiKey(hit.value, 'llm-deepseek', ref) } else { // Without the seam, keep the historical ambient fallback so a plain // cordis.yml composition works from the environment alone. const ambient = process.env[ref] - if (ambient !== undefined && ambient.length > 0) return ambient + if (ambient !== undefined && ambient.length > 0) return assertUsableApiKey(ambient, 'llm-deepseek', ref) } throw new LlmError( `llm-deepseek: no API key for provider route "${PROVIDER}"; store ${ref} through the credentials` diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 9d104ace08..56ac3eb138 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -991,3 +991,33 @@ describe('plugin registration and config', () => { expect(ctx.llm.listProviders()).toEqual([]) }) }) + +describe('API key format', () => { + it('trims a padded literal apiKey', () => { + expect(resolveAdapterOptions({ apiKey: ' sk-abc ' }).apiKey).toBe('sk-abc') + }) + + it('leaves an omitted apiKey absent so apiKeyEnv still resolves it', () => { + expect(resolveAdapterOptions({}).apiKey).toBeUndefined() + }) + + it('rejects a literal apiKey of whitespace only', () => { + expect(() => resolveAdapterOptions({ apiKey: ' ' })) + .toThrow(/apiKey is empty; omit it/) + }) + + it('rejects a literal apiKey no header can carry', () => { + expect(() => resolveAdapterOptions({ apiKey: 'sk-\u{1F600}' })) + .toThrow(/no HTTP header can carry/) + }) + + it('never echoes the key in the rejection', () => { + const secret = 'sk-\u{1F600}supersecret' + expect(() => resolveAdapterOptions({ apiKey: secret })).toThrow() + try { + resolveAdapterOptions({ apiKey: secret }) + } catch (error) { + expect((error as Error).message).not.toContain('supersecret') + } + }) +}) diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts index 11df9e1d81..e593e3a61d 100644 --- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -3,7 +3,7 @@ import { Context } from 'cordis' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import LlmService from '@deepseek-ai/dsh-llm' +import LlmService, { INVALID_CREDENTIAL_CODE } from '@deepseek-ai/dsh-llm' import { credentialRef } from '@deepseek-ai/dsh-credentials' import { CredentialsLocal } from '@deepseek-ai/dsh-credentials-local' import { settingsNamespace } from '@deepseek-ai/dsh-settings' @@ -103,6 +103,25 @@ describe('request-level dynamic configuration', () => { expect(server.headers[0]?.authorization).toBe('Bearer sk-arrived') }) + it('rejects a stored credential no header can carry, never echoing it in the failure', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', '') + const dir = await home() + const { ctx } = await boot(dir, { baseURL: 'http://127.0.0.1:1' }) + const secret = 'sk-\u{1F600}supersecret' + + // The real credentials seam (the path the web Models page writes through), + // not a hand-built stub: this package's own dynamic-config harness already + // boots one, and round-tripping the value through its actual store/read + // path is stronger evidence than a canned in-memory return would be. + await ctx.credentials.set(KEY_REF, secret) + const result = await prompt(ctx) + expect(result.finish).toMatchObject({ kind: 'error', failure: { code: INVALID_CREDENTIAL_CODE } }) + if (result.finish.kind !== 'error') throw new Error('expected an error finish') + expect(result.finish.failure.message).not.toContain(secret) + expect(result.finish.failure.message).not.toContain('supersecret') + expect(result.finish.failure.message).not.toContain('ByteString') + }) + it('advertises a live settings catalog without re-registration', async () => { const dir = await home() const { ctx } = await boot(dir, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' }) From b1660ab8a447c66ae9c3356bc60640b5cab3e10e Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Thu, 6 Aug 2026 21:40:06 +0800 Subject: [PATCH 39/67] docs(llm-deepseek): document the invalid-credential refusal --- packages/llm/llm-deepseek/README.i18n.yaml | 4 ++-- packages/llm/llm-deepseek/README.md | 2 +- packages/llm/llm-deepseek/README.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 3eb54a7a9f..6daba653f8 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/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-deepseek/README.md -README.md: 0cd265cadb2b2a619613761062ab2cef209bec83 -README.zh.md: 1883b054277adfd6c3d02b2a76ead9b3f8b0138f +README.md: 51d5cf6a7049a2b1257ce3e9a284e777d3bdcdbc +README.zh.md: cc059a6011f7dc1ee0ab93dbd822de4b540b7e66 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 0cd265cadb..51d5cf6a70 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -53,7 +53,7 @@ The same exact-model result exposes ordered `off`, `high`, and `max` efforts und Connection facts are not frozen at load. `resolveAdapterOptions` is the one explicit resolve step from raw config to validated facts, and the adapter re-reads them through a thunk **once per operation**: base URL, catalog, request defaults, and idle budget all take effect on the next request, while an in-flight stream keeps the facts it started with. Two optional seams feed that thunk: - **`ctx.settings`** — the plugin registers the `llm-deepseek` namespace with this same `Config` schema and its `cordis.yml` entry as the composition `base`, so a `llm-deepseek:` section in the user settings document overrides any field without a restart. Without a mounted settings service the entry config alone drives the adapter, unchanged. A live settings snapshot that passes the schema but fails a beyond-schema bound (a duplicate catalog id, a broken thinking/effort pair) keeps the last good facts and logs the failure; the entry config itself still fails plugin load. -- **`ctx.credentials`** — the API key resolves per stream call, from the *same* resolved snapshot that supplies the endpoint: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the credential seam (`$DSH_HOME/.env` under the live environment), then — only without a mounted seam — the raw environment variable. Because credential facts travel with the connection facts, a settings snapshot the resolver rejects contributes neither its endpoint nor its key: the whole previous generation keeps serving. A request with no key anywhere fails with `MISSING_CREDENTIAL` naming every configuration entry point, while the route stays registered and the catalog stays browsable — first-run onboarding is "browse models, store the key, prompt again", with no restart between. +- **`ctx.credentials`** — the API key resolves per stream call, from the *same* resolved snapshot that supplies the endpoint: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the credential seam (`$DSH_HOME/.env` under the live environment), then — only without a mounted seam — the raw environment variable. Because credential facts travel with the connection facts, a settings snapshot the resolver rejects contributes neither its endpoint nor its key: the whole previous generation keeps serving. Every key is trimmed and format-checked before use — a literal `apiKey` at connection-facts resolution (plugin load, or the next settings snapshot), a stored or ambient value at request time — so a value no HTTP header can carry is refused there instead of surfacing as an opaque `fetch` `TypeError`; the request-time check throws `LlmError('INVALID_CREDENTIAL')` naming the failing entry point but never any part of the key. A request with no key anywhere fails with `MISSING_CREDENTIAL` naming every configuration entry point, while the route stays registered and the catalog stays browsable — first-run onboarding is "browse models, store the key, prompt again", with no restart between. The one registration-captured fact is the retry policy: when its resolved value changes, the plugin re-registers the route in place (same adapter instance, one synchronous section), so `ctx.llm.providerRetryPolicy('deepseek-official')` always reports the current policy. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 1883b05427..cc059a6011 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -53,7 +53,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: 连接事实不在加载时冻结。`resolveAdapterOptions` 是从原始配置到已校验事实的唯一显式 resolve 步骤,适配器经由一个 thunk **每操作重读一次**:base URL、catalog、请求默认值与 idle 预算都在下一次请求生效,进行中的流则保持其起始事实。两个可选 seam 供给该 thunk: - **`ctx.settings`**——插件用同一份 `Config` schema 注册 `llm-deepseek` namespace,并以其 `cordis.yml` 条目为组合 `base`,因此用户设置文档中的 `llm-deepseek:` 分节可以免重启覆盖任何字段。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。存活 settings 快照若通过 schema 却违反 schema 之外的约束(重复的 catalog id、无法成立的 thinking/推理强度组合),则保留最后可用事实并记录失败;entry 配置本身仍会使插件加载失败。 -- **`ctx.credentials`**——API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照:非空的字面 `apiKey` 优先,其次经凭据 seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`),最后——仅在未挂载 seam 时——读取原始环境变量。由于凭据事实与连接事实同行,被 resolver 拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥:整个先前世代继续服务。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。 +- **`ctx.credentials`**——API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照:非空的字面 `apiKey` 优先,其次经凭据 seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`),最后——仅在未挂载 seam 时——读取原始环境变量。由于凭据事实与连接事实同行,被 resolver 拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥:整个先前世代继续服务。每个密钥在使用前都会被去除首尾空白并校验格式——字面 `apiKey` 在连接事实解析时(插件加载或下一次 settings 快照)校验,已存储的值或环境变量值则在请求时校验——因此 HTTP 标头无法承载的值会在这一步被拒绝,而不是以语义不明的 `fetch` `TypeError` 形式浮现;请求时校验会抛出 `LlmError('INVALID_CREDENTIAL')`,点名失败的入口,但绝不透露密钥的任何部分。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。 唯一在注册期捕获的事实是重试策略:其解析值变化时,插件原地重新注册该路由(同一适配器实例、一个同步区段),因此 `ctx.llm.providerRetryPolicy('deepseek-official')` 始终报告当前策略。 From 45d78c92722155922a87e16a69d7bfe40d5c4eda Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Thu, 6 Aug 2026 22:12:50 +0800 Subject: [PATCH 40/67] fix(llm-pi-ai): refuse an unusable API key before the header is built --- docs/config-catalog.md | 8 +++- packages/llm/llm-pi-ai/src/config.ts | 23 ++++++++-- packages/llm/llm-pi-ai/src/discovery.ts | 26 ++++++++++- packages/llm/llm-pi-ai/src/index.ts | 4 +- packages/llm/llm-pi-ai/tests/config.spec.ts | 24 ++++++++++ .../llm/llm-pi-ai/tests/discovery.spec.ts | 45 ++++++++++++++++++- 6 files changed, 119 insertions(+), 11 deletions(-) create mode 100644 packages/llm/llm-pi-ai/tests/config.spec.ts diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a3e63d994d..6c860cbd9d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -704,7 +704,11 @@ export interface Config { /** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */ export interface PiAiProviderProfile { - /** Literal provider credential; prefer {@link apiKeyEnv}. With both absent pi-ai uses its provider-native ambient discovery. */ + /** + * Literal provider credential; prefer {@link apiKeyEnv}. With both absent pi-ai uses its + * provider-native ambient discovery. Trimmed and format-checked by {@link resolveProfiles}; a + * value no HTTP header can carry fails there rather than inside `fetch`. + */ apiKey?: string /** Credential reference (environment-variable name) resolved per request through `ctx.credentials`. */ apiKeyEnv?: string @@ -776,7 +780,7 @@ export interface PiAiModelProfile { Depends on: `CacheRetention` (`@earendil-works/pi-ai`) · `ModelThinkingLevel` (`@earendil-works/pi-ai`) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`) -Source: [`packages/llm/llm-pi-ai/src/config.ts:122`](../packages/llm/llm-pi-ai/src/config.ts) +Source: [`packages/llm/llm-pi-ai/src/config.ts:126`](../packages/llm/llm-pi-ai/src/config.ts) ## `@deepseek-ai/dsh-llm-replay` diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index 7473dbb7ae..7e8374ab9f 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -19,7 +19,7 @@ import z from 'schemastery' import { credentialRef } from '@deepseek-ai/dsh-credentials' import type { CredentialRef } from '@deepseek-ai/dsh-credentials' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' -import { resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' +import { normalizeApiKey, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-llm' import { resolveRouteModels } from './catalog.ts' import type { PiAiModelProfile } from './catalog.ts' @@ -38,7 +38,11 @@ export type { PiAiModelProfile } from './catalog.ts' /** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */ export interface PiAiProviderProfile { - /** Literal provider credential; prefer {@link apiKeyEnv}. With both absent pi-ai uses its provider-native ambient discovery. */ + /** + * Literal provider credential; prefer {@link apiKeyEnv}. With both absent pi-ai uses its + * provider-native ambient discovery. Trimmed and format-checked by {@link resolveProfiles}; a + * value no HTTP header can carry fails there rather than inside `fetch`. + */ apiKey?: string /** Credential reference (environment-variable name) resolved per request through `ctx.credentials`. */ apiKeyEnv?: string @@ -220,8 +224,18 @@ export function resolveProfiles( for (const [provider, source] of entries) { rejectRemovedFields(provider, source) if (provider.length === 0) throw new Error('llm-pi-ai: provider names must be non-empty') - if (source.apiKey !== undefined && source.apiKey.trim().length === 0) { - throw new Error(`llm-pi-ai: provider "${provider}" has an empty apiKey; omit it to use ambient authentication`) + // Omission selects the installed provider's own auth — ambient discovery + // or OAuth — so only a supplied key is judged. + let apiKey: string | undefined + if (source.apiKey !== undefined) { + const checked = normalizeApiKey(source.apiKey) + if (!checked.ok) { + throw new Error(checked.reason === 'empty' + ? `llm-pi-ai: provider "${provider}" has an empty apiKey; omit it to use ambient authentication` + : `llm-pi-ai: provider "${provider}" has an apiKey containing characters no HTTP header can carry;` + + ' paste the raw key only') + } + apiKey = checked.value } if (source.baseURL !== undefined && source.baseURL.length === 0) { throw new Error(`llm-pi-ai: provider "${provider}" has an empty baseURL`) @@ -252,6 +266,7 @@ export function resolveProfiles( const { apiKeyEnv, retryPolicy, models: _models, displayName: _displayName, ...rest } = source resolved.set(provider, { ...rest, + ...apiKey === undefined ? {} : { apiKey }, provider, displayName, ...apiKeyEnv === undefined ? {} : { apiKeyEnv: credentialRef(apiKeyEnv) }, diff --git a/packages/llm/llm-pi-ai/src/discovery.ts b/packages/llm/llm-pi-ai/src/discovery.ts index bff2c9a7ca..014c9c2f3e 100644 --- a/packages/llm/llm-pi-ai/src/discovery.ts +++ b/packages/llm/llm-pi-ai/src/discovery.ts @@ -22,7 +22,7 @@ * @module dsh-llm-pi-ai/discovery */ -import { LlmError } from '@deepseek-ai/dsh-llm' +import { INVALID_CREDENTIAL_CODE, LlmError, normalizeApiKey } from '@deepseek-ai/dsh-llm' import type { LlmDiscoveredModel, LlmModelDiscoveryRequest } from '@deepseek-ai/dsh-llm' import { attributionHeaders } from '@deepseek-ai/dsh-llm' import { catalogModels } from './catalog.ts' @@ -161,6 +161,25 @@ function readListing(body: unknown): LlmDiscoveredModel[] { return models } +/** + * Accept one probe key, or refuse it before the header is built. Without this + * the `fetch` below would throw a ByteString `TypeError` that this function's + * catch reports as `could not reach <url>` — blaming the network for a local, + * deterministic fault. + * @param raw - the key typed into the form or read from storage. + * @returns the trimmed, usable key. + */ +function usableProbeKey(raw: string): string { + const checked = normalizeApiKey(raw) + if (checked.ok) return checked.value + throw new LlmError( + checked.reason === 'empty' + ? 'this provider\'s API key is blank; enter it on the Models page, or clear it to probe unauthenticated' + : 'this provider\'s API key contains characters no HTTP header can carry; paste the raw key only', + INVALID_CREDENTIAL_CODE, + ) +} + /** * Interrogate one draft provider endpoint for the models it advertises. * @param request - the endpoint, protocol, and one-shot credential to use. @@ -216,7 +235,10 @@ export async function discoverModels( // stored one is only asked for here, past the catalog short-circuit and the // protocol check, so a route answered from the registry costs no credential // lookup — and no diagnostic about a credential it never needed. - const apiKey = request.apiKey ?? await storedApiKey?.() + // A probe carrying no key stays unauthenticated, which is how a route that + // relies on the provider's own ambient discovery is meant to be asked. + const supplied = request.apiKey ?? await storedApiKey?.() + const apiKey = supplied === undefined ? undefined : usableProbeKey(supplied) let response: Response try { response = await fetch(url, { diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index 0d058e94ac..c30fd3db6f 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -43,7 +43,7 @@ */ import type { Context } from 'cordis' -import { LlmError } from '@deepseek-ai/dsh-llm' +import { assertUsableApiKey, LlmError } from '@deepseek-ai/dsh-llm' import type { AdapterRegistrationHandle, DirectoryRegistrationHandle, LlmConfigurableProvider } from '@deepseek-ai/dsh-llm' import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' import { PiAiAdapter } from './adapter.ts' @@ -145,7 +145,7 @@ export function apply(ctx: Context, config: Config): void { // Without the seam, read exactly the named variable so a plain // cordis.yml composition works from the environment alone. : process.env[ref] - if (hit !== undefined && hit.length > 0) return hit + if (hit !== undefined && hit.length > 0) return assertUsableApiKey(hit, 'llm-pi-ai', ref) throw new LlmError( `llm-pi-ai: no credential for provider route "${provider}"; its profile resolves ${ref}, which is not` + ` set — store ${ref} through the credentials service (the web Models page writes it) or export it,` diff --git a/packages/llm/llm-pi-ai/tests/config.spec.ts b/packages/llm/llm-pi-ai/tests/config.spec.ts new file mode 100644 index 0000000000..90f8487ad8 --- /dev/null +++ b/packages/llm/llm-pi-ai/tests/config.spec.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { resolveProfiles } from '../src/config.ts' + +describe('API key format', () => { + it('trims a padded literal apiKey into the resolved profile', () => { + const resolved = resolveProfiles({ openai: { apiKey: ' sk-abc ', baseURL: 'https://acme.test' } }) + expect(resolved.get('openai')?.apiKey).toBe('sk-abc') + }) + + it('keeps an omitted apiKey absent so ambient authentication still applies', () => { + const resolved = resolveProfiles({ openai: { baseURL: 'https://acme.test' } }) + expect(resolved.get('openai')?.apiKey).toBeUndefined() + }) + + it('still tells an empty apiKey to omit itself', () => { + expect(() => resolveProfiles({ openai: { apiKey: ' ', baseURL: 'https://acme.test' } })) + .toThrow(/omit it to use ambient authentication/) + }) + + it('rejects an apiKey no header can carry', () => { + expect(() => resolveProfiles({ openai: { apiKey: 'sk-\u{1F600}', baseURL: 'https://acme.test' } })) + .toThrow(/no HTTP header can carry/) + }) +}) diff --git a/packages/llm/llm-pi-ai/tests/discovery.spec.ts b/packages/llm/llm-pi-ai/tests/discovery.spec.ts index 916700fbbf..63b43ecdab 100644 --- a/packages/llm/llm-pi-ai/tests/discovery.spec.ts +++ b/packages/llm/llm-pi-ai/tests/discovery.spec.ts @@ -1,6 +1,6 @@ import { createServer } from 'node:http' import type { IncomingMessage, Server, ServerResponse } from 'node:http' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import LlmService, { userAgent } from '@deepseek-ai/dsh-llm' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' @@ -12,6 +12,9 @@ const servers: Server[] = [] const touchedEnv: string[] = [] afterEach(async () => { + // A no-op when the test never stubbed `fetch`; only 'probe key format' + // below installs one. + vi.unstubAllGlobals() for (const name of touchedEnv.splice(0)) Reflect.deleteProperty(process.env, name) await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve)))) }) @@ -311,3 +314,43 @@ describe('draft-provider model discovery', () => { .rejects.toMatchObject({ code: 'NO_DISCOVERY' }) }) }) + +describe('probe key format', () => { + it('reports an illegal probe key as a credential fault, not an unreachable endpoint', async () => { + await expect(discoverModels({ + baseURL: 'https://acme.test', + api: 'openai-completions', + apiKey: 'sk-\u{1F600}', + })).rejects.toMatchObject({ code: 'INVALID_CREDENTIAL' }) + }) + + it('reports a blank probe key as a credential fault too', async () => { + // A cleared form field arrives as '', not an absent key; it must fail the + // same way a typed-in illegal key does, rather than probing unauthenticated. + await expect(discoverModels({ + baseURL: 'https://acme.test', + api: 'openai-completions', + apiKey: '', + })).rejects.toMatchObject({ code: 'INVALID_CREDENTIAL' }) + }) + + it('leaves a probe with no key unauthenticated', async () => { + // The file's other cases capture headers through a real local HTTP server + // (`listingServer`); this one has no route or stored key to resolve, so + // the smallest real double is a `fetch` stub, scoped to this test and + // unstubbed by the shared `afterEach` above. + const requests: RequestInit[] = [] + vi.stubGlobal('fetch', async (_url: string | URL, init?: RequestInit) => { + requests.push(init ?? {}) + return new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + }) + + await discoverModels({ baseURL: 'https://acme.test', api: 'openai-completions' }) + + const headers = new Headers(requests[0]?.headers) + expect(headers.has('authorization')).toBe(false) + }) +}) From 665b5697bb46d87b85c832dec2689685f37edbd7 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Thu, 6 Aug 2026 22:13:02 +0800 Subject: [PATCH 41/67] docs(llm-pi-ai): document the invalid-credential refusal --- packages/llm/llm-pi-ai/README.i18n.yaml | 4 ++-- packages/llm/llm-pi-ai/README.md | 4 ++-- packages/llm/llm-pi-ai/README.zh.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index b4e9cffabb..bd322be07f 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: af0e952dd8dbd9767b98229ee6b87262007d6738 -README.zh.md: f8a19999f08aa8a6963874d57bf74370797b951c +README.md: 0dcf15d6caf365a1f8e75088cb363eaa6560a6ec +README.zh.md: 79be5d320c0f4411f7cf8a0bd72c887048929dcb diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index af0e952dd8..0dcf15d6ca 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -67,7 +67,7 @@ Resolution still fails loud, naming the offending route and model, when a route The adapter reads its profiles through a thunk **once per operation** instead of freezing them at construction. The plugin registers the `llm-pi-ai` namespace on the optional `ctx.settings` seam with this same `Config` schema and its `cordis.yml` entry as the composition `base`, and because `providers` is a dict, the base and the user's `llm-pi-ai:` settings section merge **per provider**: a user can add a route, override one field of a composition route, or point a route at another proxy, all effective on the next request with no restart. Without a mounted settings service the entry config alone drives the adapter, unchanged. -Credentials resolve per stream call: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the optional `ctx.credentials` seam (`$DSH_HOME/.env` under the live environment; exactly that variable without a mounted seam). A profile naming no credential at all — and only that case — defers to pi-ai's ambient discovery. The route set and each route's captured retry policy are the registration-level facts: when either changes, the plugin replaces its registration atomically (same adapter instance, candidate set validated first), so a route another adapter already owns leaves the previous routes serving and reverting to a working configuration re-applies. Provider key order never counts as a change. A section this adapter could not serve is refused where it is written — the registered `validate` resolves the whole profile set, so `ctx.settings.mutate` rejects with the resolver's own error (the wire surface reports it as `settings-rejected`) and nothing is stored. A stored section that becomes unserviceable some other way — an external edit of `settings.yaml` — keeps the namespace's last good value at the settings seam and warns. The entry config itself still fails plugin load, and a route the llm registry refuses (one another adapter family already owns) is logged while the previously registered routes keep serving. +Credentials resolve per stream call: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the optional `ctx.credentials` seam (`$DSH_HOME/.env` under the live environment; exactly that variable without a mounted seam). A profile naming no credential at all — and only that case — defers to pi-ai's ambient discovery. Every key is trimmed and format-checked before use — a literal `apiKey` when profiles resolve (plugin load, or the next settings snapshot), a value `apiKeyEnv` resolves at request time — so a value no HTTP header can carry is refused there instead of surfacing as an opaque `fetch` `TypeError`; the request-time refusal throws `LlmError('INVALID_CREDENTIAL')` naming the failing route and credential reference but never any part of the key. The route set and each route's captured retry policy are the registration-level facts: when either changes, the plugin replaces its registration atomically (same adapter instance, candidate set validated first), so a route another adapter already owns leaves the previous routes serving and reverting to a working configuration re-applies. Provider key order never counts as a change. A section this adapter could not serve is refused where it is written — the registered `validate` resolves the whole profile set, so `ctx.settings.mutate` rejects with the resolver's own error (the wire surface reports it as `settings-rejected`) and nothing is stored. A stored section that becomes unserviceable some other way — an external edit of `settings.yaml` — keeps the namespace's last good value at the settings seam and warns. The entry config itself still fails plugin load, and a route the llm registry refuses (one another adapter family already owns) is logged while the previously registered routes keep serving. The adapter exposes each configured route's models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata read from the same pi-ai `Models` collection the request path uses, so discovery does not create a second model registry. `ctx.llm.resolveModelInfo(provider, model)` performs that exact descriptor lookup once and returns its identity, context window, configured output cap, and selectable thinking levels, keeping authoritative metadata on the route-owning adapter rather than its consumers. A model's **configured** `maxTokens` becomes the seam's `defaultMaxTokens`, so a request that names no output cap carries the one the deployment chose; a value inherited from the installed catalog is the model's output *capability* and never becomes a request default on its own. @@ -85,7 +85,7 @@ The plugin offers `ctx.llm.registerModelDiscovery('llm-pi-ai', …)`, which answ A request naming a route the **installed catalog ships is answered from that catalog**, with no network call: pi-ai's registry is the authoritative list for its own providers, and it carries the context windows and output caps a listing endpoint would not disclose. Such a route needs no `baseURL` at all. Only a route the catalog does not describe — a gateway, a self-hosted server — is interrogated over the wire, and one that names no endpoint is told to set one or enter its models by hand. -A draft carries the credential the user typed, if any; a route that already stored one shows a configuration surface only a redacted descriptor, so the interrogation supplies that route's own credential — resolved exactly as a request to it would, `apiKey` then `apiKeyEnv` — rather than going out unauthenticated and reporting the endpoint's 401 as a wrong key. A typed key wins, being the one under test. Resolution happens only on the path that reaches the network, so a catalog route answers without touching credentials at all. +A draft carries the credential the user typed, if any; a route that already stored one shows a configuration surface only a redacted descriptor, so the interrogation supplies that route's own credential — resolved exactly as a request to it would, `apiKey` then `apiKeyEnv` — rather than going out unauthenticated and reporting the endpoint's 401 as a wrong key. A typed key wins, being the one under test. Resolution happens only on the path that reaches the network, so a catalog route answers without touching credentials at all. A supplied or stored probe key is trimmed and format-checked the same way, so a value no HTTP header can carry is refused immediately as `LlmError('INVALID_CREDENTIAL')` instead of reaching `fetch`, where it would surface as an opaque `ByteString` failure indistinguishable from an unreachable endpoint. Interrogation reads `openai-completions` and `openai-responses`, whose `GET /models` shape with bearer auth is the one a gateway, a self-hosted server, and the official endpoints all agree on. Azure is excluded despite its OpenAI lineage — it authenticates with an `api-key` header and requires an `api-version` query — and Codex uses OAuth; every other protocol answers `DISCOVERY_UNSUPPORTED` so the surface falls back to hand-entry instead of an authentication failure being reported as a provider with no models. The `baseURL` is treated as a prefix rather than a URL to resolve against, so a deployment path such as `https://gateway.example/openai/v1` keeps its segments. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index f8a19999f0..79be5d320c 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -67,7 +67,7 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩 适配器经由一个 thunk **每操作读取一次** profile,而非在构造期冻结。插件在可选的 `ctx.settings` seam 上用同一份 `Config` schema 注册 `llm-pi-ai` namespace,并以其 `cordis.yml` 条目为组合 `base`;由于 `providers` 是字典,base 与用户的 `llm-pi-ai:` settings 分节**按提供方**合并:用户可以新增路由、覆盖组合路由的单个字段,或把路由指向另一个 proxy,全部在下一次请求生效,无需重启。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。 -凭据按每次 stream 调用解析:非空的字面 `apiKey` 优先,其次经可选的 `ctx.credentials` seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`;未挂载 seam 时恰好读取该环境变量)。只有完全没有点名任何凭据的 profile——仅限这一种情况——才交给 pi-ai 的环境发现。路由集合与每条路由捕获的重试策略是注册级事实:两者任一变化时,插件都会原子地替换自己的注册(同一适配器实例,候选集合先经校验),因此某条路由若已被另一适配器占有,先前的路由会继续服务,而改回可用配置时注册会重新生效。提供方键的顺序绝不算作变化。本适配器无法服务的分节会在写入处被拒——注册的 `validate` 会解析整份 profile 集合,因此 `ctx.settings.mutate` 以 resolver 自身的错误拒绝(协议面将其报为 `settings-rejected`),什么都不会存储。已存储分节若因其他途径变得不可服务——比如外部编辑了 `settings.yaml`——则由 settings seam 保留该 namespace 最后可用的值并告警。entry 配置本身仍会使插件加载失败;而 llm 注册表拒绝的路由(已被另一适配器族占有的那种)会被记录下来,先前注册的路由继续服务。 +凭据按每次 stream 调用解析:非空的字面 `apiKey` 优先,其次经可选的 `ctx.credentials` seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`;未挂载 seam 时恰好读取该环境变量)。只有完全没有点名任何凭据的 profile——仅限这一种情况——才交给 pi-ai 的环境发现。每个密钥在使用前都会被去除首尾空白并校验格式——字面 `apiKey` 在 profile 解析时(插件加载,或下一次 settings 快照)校验,`apiKeyEnv` 解析出的值则在请求时校验——因此 HTTP 标头无法承载的值会在这一步被拒绝,而不是以语义不明的 `fetch` `TypeError` 形式浮现;请求时的拒绝会抛出 `LlmError('INVALID_CREDENTIAL')`,点名失败的路由与凭据引用,但绝不透露密钥的任何部分。路由集合与每条路由捕获的重试策略是注册级事实:两者任一变化时,插件都会原子地替换自己的注册(同一适配器实例,候选集合先经校验),因此某条路由若已被另一适配器占有,先前的路由会继续服务,而改回可用配置时注册会重新生效。提供方键的顺序绝不算作变化。本适配器无法服务的分节会在写入处被拒——注册的 `validate` 会解析整份 profile 集合,因此 `ctx.settings.mutate` 以 resolver 自身的错误拒绝(协议面将其报为 `settings-rejected`),什么都不会存储。已存储分节若因其他途径变得不可服务——比如外部编辑了 `settings.yaml`——则由 settings seam 保留该 namespace 最后可用的值并告警。entry 配置本身仍会使插件加载失败;而 llm 注册表拒绝的路由(已被另一适配器族占有的那种)会被记录下来,先前注册的路由继续服务。 适配器通过 `ctx.llm.listModels(provider)` 公开每条已配置路由的模型。这是从请求路径所用的同一个 pi-ai `Models` 集合读取的提供方无关 selector 元数据,因此发现不会创建第二个模型注册表。`ctx.llm.resolveModelInfo(provider, model)` 会执行一次精确 descriptor 查找,并返回其身份、上下文窗口、已配置输出上限和可选思考级别,让权威元数据保留在拥有路由的适配器上,而非消费方。模型**已配置**的 `maxTokens` 会成为 seam 的 `defaultMaxTokens`,因此未点名输出上限的请求会携带部署选定的那一个;而从已安装 catalog 继承来的值是模型的输出**能力**,绝不会自行变成请求默认值。 @@ -85,7 +85,7 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩 点名了**已安装 catalog 所提供路由**的请求,直接由该 catalog 作答,完全不联网:pi-ai 的注册表才是它自家提供方的权威列表,且携带列表端点不会公布的上下文窗口与输出上限。这类路由根本不需要 `baseURL`。只有 catalog 未描述的路由——网关、自建服务——才会经协议层询问;若它也没给端点,则会被告知去设置一个或手工填写模型。 -草稿携带的是用户当下键入的凭据(如果有);已经存好凭据的路由,在配置界面上只呈现一个脱敏描述符,因此询问会自行取用该路由的凭据——解析方式与向它发请求时完全一致,先 `apiKey` 后 `apiKeyEnv`——而不是不带认证发出去、再把端点的 401 报成密钥不对。键入的密钥优先,因为那正是被测试的那一把。解析只发生在真正要联网的路径上,因此 catalog 路由作答时完全不会触碰凭据。 +草稿携带的是用户当下键入的凭据(如果有);已经存好凭据的路由,在配置界面上只呈现一个脱敏描述符,因此询问会自行取用该路由的凭据——解析方式与向它发请求时完全一致,先 `apiKey` 后 `apiKeyEnv`——而不是不带认证发出去、再把端点的 401 报成密钥不对。键入的密钥优先,因为那正是被测试的那一把。解析只发生在真正要联网的路径上,因此 catalog 路由作答时完全不会触碰凭据。用户提供或已存储的探测密钥也会经过同样的去除空白与格式校验:HTTP 标头无法承载的值会被立即以 `LlmError('INVALID_CREDENTIAL')` 拒绝,而不会传到 `fetch`——否则会呈现为一个和端点不可达难以区分的、语义不明的 `ByteString` 失败。 询问只读 `openai-completions` 与 `openai-responses`,它们「`GET /models` + bearer 认证」的形状是网关、自建服务与官方端点三方一致认可的那一种。Azure 尽管出身 OpenAI 也被排除——它用 `api-key` 标头认证并要求 `api-version` 查询参数——Codex 则走 OAuth;其余协议一律以 `DISCOVERY_UNSUPPORTED` 回答,让界面回退到手工填写,而不是把认证失败报成一个没有模型的提供方。`baseURL` 按前缀而非待解析 URL 处理,因此 `https://gateway.example/openai/v1` 这类部署路径会保留其路径段。 From cf9eade39d5019ccbbd90e0f1a969274074ea691 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Thu, 6 Aug 2026 22:30:43 +0800 Subject: [PATCH 42/67] feat(web): refuse an unusable API key on the field that holds it --- .../src/client/CustomProviderCard.tsx | 14 ++- .../ui-models/src/client/ProviderEditor.tsx | 19 +++- .../client/ui-models/src/client/apiKey.ts | 50 +++++++++ .../client/ui-models/src/client/locales.ts | 6 ++ .../ui-models/tests/components.spec.tsx | 50 +++++++++ .../ui-models/tests/provider-form.spec.tsx | 102 ++++++++++++++++++ 6 files changed, 233 insertions(+), 8 deletions(-) create mode 100644 packages/client/ui-models/src/client/apiKey.ts diff --git a/packages/client/ui-models/src/client/CustomProviderCard.tsx b/packages/client/ui-models/src/client/CustomProviderCard.tsx index b4c655472a..a252d99586 100644 --- a/packages/client/ui-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-models/src/client/CustomProviderCard.tsx @@ -18,6 +18,7 @@ import { useState } from 'react' import type { ReactNode } from 'react' import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' +import { apiKeyFailure } from './apiKey.ts' import { EditorFooter } from './EditorFooter.tsx' import { validateDeepSeekModels } from './DeepSeekModelsEditor.tsx' import { ModelListEditor } from './ModelListEditor.tsx' @@ -80,8 +81,14 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { // bad row is named by its position here too. Capacities have route-level // fallbacks; what a route cannot default is at least one model. const modelFailure = validateDeepSeekModels(models) + const keyFailure = apiKeyFailure(keyDraft) + // The typed key with paste whitespace removed. A blank field yields an empty + // string, which the create path reads as "no key supplied" — a route may + // legitimately authenticate through the provider's own ambient discovery. + const keyValue = keyDraft.trim() 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 // nothing at all rather than printing an empty paragraph. @@ -112,8 +119,8 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { expectedRevision: openedAt, }) if (!response.result.ok) return response.result.error.message - if (keyDraft.length > 0) { - const stored = await api.credentials.set({ ref: keyRef, value: keyDraft }) + if (keyValue.length > 0) { + const stored = await api.credentials.set({ ref: keyRef, value: keyValue }) // The profile landed; saying the key did not is the only honest report, // and the row is now editable so the key can be entered again there. if (!stored.result.ok) return stored.result.error.message @@ -208,6 +215,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { disabled={disabled} onChange={(event) => { setKeyDraft(event.target.value) }} /> + {keyFailure === undefined ? null : <p className={styles['error']}>{t(keyFailure)}</p>} </div> <ModelListEditor models={models} @@ -216,7 +224,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { settingsNs: NS, baseURL, api: protocol, - ...keyDraft.length === 0 ? {} : { apiKey: keyDraft }, + ...keyValue.length === 0 ? {} : { apiKey: keyValue }, }} api={api} t={t} diff --git a/packages/client/ui-models/src/client/ProviderEditor.tsx b/packages/client/ui-models/src/client/ProviderEditor.tsx index f48572cc58..f33791eab5 100644 --- a/packages/client/ui-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-models/src/client/ProviderEditor.tsx @@ -22,6 +22,7 @@ import { import { DeepSeekModelsEditor, modelDrafts, validateDeepSeekModels, } from './DeepSeekModelsEditor.tsx' +import { apiKeyFailure } from './apiKey.ts' import { EditorFooter } from './EditorFooter.tsx' import { ModelListEditor } from './ModelListEditor.tsx' import { deriveKeyRef, messageOf } from './store.ts' @@ -163,7 +164,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { const stringAt = (source: unknown, key: string): string | undefined => { const value = getPath(source, [key]) - return typeof value === 'string' && value.length > 0 ? value : undefined + return typeof value === 'string' && value.trim().length > 0 ? value : undefined } const setField = (key: string, next: string | undefined): void => { setDraft(current => next === undefined ? deletePath(current, [key]) : setPath(current, [key], next)) @@ -172,6 +173,12 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { // The model list is validated by the same per-row checker for both families, // so a bad row is named by its position rather than by a blanket message. const modelFailure = validateDeepSeekModels(getPath(draft, ['models'])) + const keyFailure = apiKeyFailure(keyDraft) + // What a probe or a write must carry: the typed key with paste whitespace + // removed. A blank field yields an empty string, which both call sites read + // as "no key supplied" rather than as a key — that is how a card whose + // provider already has a stored key is edited without re-entering it. + const keyValue = keyDraft.trim() // What the form currently shows, which is what an interrogation must ask: // an edited-but-unsaved endpoint, and a key typed but not yet stored. const probeApi = stringAt(draft, 'api') ?? stringAt(fallback, 'api') @@ -183,7 +190,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { provider: props.provider, ...probeBaseURL === undefined ? {} : { baseURL: probeBaseURL }, ...probeApi === undefined ? {} : { api: probeApi }, - ...keyDraft.length === 0 ? {} : { apiKey: keyDraft }, + ...keyValue.length === 0 ? {} : { apiKey: keyValue }, } /** * The write for this card, or a failure message. Every edit travels as @@ -226,8 +233,8 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { : response.result.error.message } } - if (keyDraft.length > 0) { - const stored = await api.credentials.set({ ref: keyRef, value: keyDraft }) + if (keyValue.length > 0) { + const stored = await api.credentials.set({ ref: keyRef, value: keyValue }) if (!stored.result.ok) return stored.result.error.message } setKeyDraft('') @@ -313,6 +320,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { disabled={disabled || keyLocked} onChange={(event) => { setKeyDraft(event.target.value) }} /> + {keyFailure === undefined ? null : <p className={styles['error']}>{t(keyFailure)}</p>} </div> <details className={styles['customized']}> <summary className={styles['customizedSummary']}>{t('customized')}</summary> @@ -396,7 +404,8 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { <EditorFooter t={t} busy={busy} - submitDisabled={disabled || layout === 'unknown' || modelFailure !== undefined} + submitDisabled={disabled || layout === 'unknown' || modelFailure !== undefined + || keyFailure !== undefined} submitLabel="apply" submitBusyLabel="applying" onCancel={() => { props.onClose(false) }} diff --git a/packages/client/ui-models/src/client/apiKey.ts b/packages/client/ui-models/src/client/apiKey.ts new file mode 100644 index 0000000000..a9d5bb3d32 --- /dev/null +++ b/packages/client/ui-models/src/client/apiKey.ts @@ -0,0 +1,50 @@ +/** + * Browser-side judgement of a typed API key. + * @module @deepseek-ai/dsh-client-ui-models/apiKey + */ + +/** + * Twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`: printable ASCII, space + * excluded. Client packages reference only client packages, so the charset + * rule is mirrored here rather than imported; keep the two in step, as + * `validateDeepSeekModels` is kept in step with the host's `catalogModel`. + */ +const LEGAL_API_KEY = /^[\x21-\x7E]+$/ + +/** + * A pasted `NAME=value` environment line. Restricted to an upper-case + * identifier so a real key cannot match: `sk-` forms break at the hyphen. + * This heuristic runs only here — a resolver applying it could lock a user + * out of a gateway whose key legitimately takes this shape, with the + * environment refusing it too and no way through. + */ +const ENV_LINE = /^[A-Z][A-Z0-9_]*=/ + +/** Copy key naming why a typed key cannot be saved. */ +export type ApiKeyFailureKey = 'keyBlank' | 'keyIllegalCharacters' | 'keyLooksWrapped' + +/** Whether a value is wrapped in one matching pair of quotes. */ +function isQuoted(value: string): boolean { + const first = value[0] + if (first !== '"' && first !== '\'' && first !== '`') return false + return value.length > 1 && value.endsWith(first) +} + +/** + * Judge the key input's current value. + * + * An empty field is not a failure: every card opens with it empty even when a + * key is already stored, where it means keep that one. A field holding only + * whitespace is a failure rather than an empty field, so typed input is never + * silently discarded. + * @param draft - the key input's current value, untrimmed. + * @returns the copy key for a field-level failure, or `undefined` to allow submit. + */ +export function apiKeyFailure(draft: string): ApiKeyFailureKey | undefined { + if (draft.length === 0) return undefined + const value = draft.trim() + if (value.length === 0) return 'keyBlank' + if (ENV_LINE.test(value) || isQuoted(value)) return 'keyLooksWrapped' + if (!LEGAL_API_KEY.test(value)) return 'keyIllegalCharacters' + return undefined +} diff --git a/packages/client/ui-models/src/client/locales.ts b/packages/client/ui-models/src/client/locales.ts index 19463d98fa..fbfc85c7f1 100644 --- a/packages/client/ui-models/src/client/locales.ts +++ b/packages/client/ui-models/src/client/locales.ts @@ -46,6 +46,9 @@ export const en = { addModel: 'Add model', removeModel: 'Delete model', modelsEmpty: 'No models will be shown in the selector. Unlisted IDs can still be sent directly.', + keyBlank: 'Enter the API key, or leave the field empty to keep the stored one.', + keyIllegalCharacters: 'This API key contains characters that cannot be sent. Paste the raw key only.', + keyLooksWrapped: 'Paste only the key itself — not a NAME=value line, and without surrounding quotes.', modelIdRequired: 'Model ID is required.', modelIdDuplicate: 'Model ID must be unique.', modelNameInvalid: 'Display name cannot be empty.', @@ -130,6 +133,9 @@ export const zh: typeof en = { addModel: '添加模型', removeModel: '删除模型', modelsEmpty: '模型选择器中将不显示任何模型;目录外 ID 仍可直接发送。', + keyBlank: '请输入 API 密钥;留空则保持已存储的密钥。', + keyIllegalCharacters: '该 API 密钥含有无法发送的字符。请只粘贴原始密钥。', + keyLooksWrapped: '请只粘贴密钥本身——不要带 NAME=value 整行,也不要带引号。', modelIdRequired: '模型 ID 不能为空。', modelIdDuplicate: '模型 ID 不能重复。', modelNameInvalid: '显示名称不能为空。', diff --git a/packages/client/ui-models/tests/components.spec.tsx b/packages/client/ui-models/tests/components.spec.tsx index aa9082e7dd..d9034ecd44 100644 --- a/packages/client/ui-models/tests/components.spec.tsx +++ b/packages/client/ui-models/tests/components.spec.tsx @@ -11,6 +11,7 @@ import { pathOps } from '../src/client/ProviderEditor.tsx' import { DeepSeekModelsEditor, formatCapacity, modelDrafts, parseCapacity, validateDeepSeekModels, } from '../src/client/DeepSeekModelsEditor.tsx' +import { apiKeyFailure } from '../src/client/apiKey.ts' import { deriveKeyRef, ModelsSettingsStore } from '../src/client/store.ts' import type { ProviderRow } from '../src/client/store.ts' import { en } from '../src/client/locales.ts' @@ -1080,3 +1081,52 @@ describe('ModelsSection', () => { expect(failure).toBe('connection lost') }) }) + +describe('apiKeyFailure', () => { + it('treats a blank field as no failure — it means keep the stored key', () => { + expect(apiKeyFailure('')).toBeUndefined() + }) + + it.each([ + ['a printable-ASCII key', 'sk-0123456789'], + ['a padded key, which the caller trims', ' sk-abc '], + ['the printable-ASCII boundary characters', '!~'], + ['a hyphenated key carrying an equals sign', 'sk-ABC=xyz'], + ])('accepts %s', (_label, draft) => { + expect(apiKeyFailure(draft)).toBeUndefined() + }) + + it.each([ + ['spaces', ' '], + ['a tab', '\t'], + ])('fails a field holding only %s instead of silently dropping it', (_label, draft) => { + expect(apiKeyFailure(draft)).toBe('keyBlank') + }) + + it.each([ + ['an emoji', 'sk-\u{1F600}'], + ['CJK text', 'sk-你好'], + ['full-width punctuation', 'sk-abc,'], + ['an interior space', 'sk-abc def'], + ['a C0 control character', 'sk-abc\x01'], + ['a latin-1 character', 'sk-café'], + ])('fails %s as illegal characters', (_label, draft) => { + expect(apiKeyFailure(draft)).toBe('keyIllegalCharacters') + }) + + it.each([ + ['a pasted environment line', 'DEEPSEEK_API_KEY=sk-abc'], + ['double quotes', '"sk-abc"'], + ['single quotes', '\'sk-abc\''], + ['backticks', '`sk-abc`'], + ])('fails %s as wrapped', (_label, draft) => { + expect(apiKeyFailure(draft)).toBe('keyLooksWrapped') + }) + + it('needs a matching closing quote before it calls a value wrapped', () => { + // A lone quote and an unbalanced one are legal printable ASCII, so the + // heuristic leaves them alone rather than guessing at a paste error. + expect(apiKeyFailure('"')).toBeUndefined() + expect(apiKeyFailure('"a')).toBeUndefined() + }) +}) diff --git a/packages/client/ui-models/tests/provider-form.spec.tsx b/packages/client/ui-models/tests/provider-form.spec.tsx index 99e85b0d10..a167710153 100644 --- a/packages/client/ui-models/tests/provider-form.spec.tsx +++ b/packages/client/ui-models/tests/provider-form.spec.tsx @@ -862,4 +862,106 @@ describe('hand-declared providers', () => { await waitFor(() => { expect(screen.queryByText(en.customTitle)).toBeNull() }) expect(screen.getByRole('button', { name: en.customAdd })).toBeTruthy() }) + + it('refuses an unusable key on the field and blocks creation', () => { + const { mutate, set } = mountCard() + + fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme-gateway' } }) + fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://gateway.acme.example/v1' } }) + fireEvent.click(screen.getByRole('button', { name: en.addModel })) + fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } }) + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-\u{1F600}' } }) + + // A hand-declared route reaches the same judgement as an edited one, so a + // key that no header can carry never becomes a profile plus a bad secret. + expect(screen.getByText(en.keyIllegalCharacters)).toBeTruthy() + expect(buttonNamed(en.create).disabled).toBe(true) + expect(mutate).not.toHaveBeenCalled() + expect(set).not.toHaveBeenCalled() + }) + + it('creates without a key when the route authenticates some other way', async () => { + const { set, onClose } = mountCard() + + fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'ambient-gateway' } }) + fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://gateway.acme.example/v1' } }) + fireEvent.click(screen.getByRole('button', { name: en.addModel })) + fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } }) + fireEvent.click(screen.getByText(en.create)) + + await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) }) + expect(set).not.toHaveBeenCalled() + }) +}) + +describe('API key field', () => { + it('submits with a blank key field without writing a credential', async () => { + const { mutate, set } = await mountSection() + openEditor('openai') + + // The field opens empty even for a provider whose key is stored, where it + // means "keep that one" — so editing anything else must not require it. + fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://moved.example/v1' } }) + expect(buttonNamed(en.apply).disabled).toBe(false) + fireEvent.click(screen.getByText(en.apply)) + + await waitFor(() => { expect(mutate).toHaveBeenCalled() }) + expect(set).not.toHaveBeenCalled() + }) + + it('blocks submit and names the field when the key holds only whitespace', async () => { + const { mutate, set } = await mountSection() + openEditor('openai') + + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: ' ' } }) + + expect(screen.getByText(en.keyBlank)).toBeTruthy() + expect(buttonNamed(en.apply).disabled).toBe(true) + expect(mutate).not.toHaveBeenCalled() + expect(set).not.toHaveBeenCalled() + }) + + it('blocks submit when the key contains characters no header can carry', async () => { + const { set } = await mountSection() + openEditor('openai') + + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-\u{1F600}' } }) + + expect(screen.getByText(en.keyIllegalCharacters)).toBeTruthy() + expect(buttonNamed(en.apply).disabled).toBe(true) + expect(set).not.toHaveBeenCalled() + }) + + it('blocks submit when a whole NAME=value line was pasted', async () => { + await mountSection() + openEditor('openai') + + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'OPENAI_API_KEY=sk-abc' } }) + + expect(screen.getByText(en.keyLooksWrapped)).toBeTruthy() + expect(buttonNamed(en.apply).disabled).toBe(true) + }) + + it('trims a padded key before storing it', async () => { + const { set } = await mountSection() + openEditor('openai') + + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: ' sk-abc ' } }) + expect(buttonNamed(en.apply).disabled).toBe(false) + fireEvent.click(screen.getByText(en.apply)) + + await waitFor(() => { expect(set).toHaveBeenCalled() }) + expect((set.mock.calls[0]?.[0] as { value: string }).value).toBe('sk-abc') + }) + + it('carries the trimmed key into an interrogation, not the padded draft', async () => { + const { discover } = await mountSection() + openEditor('openai') + + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: ' sk-abc ' } }) + fireEvent.click(screen.getByRole('button', { name: en.fetchModels })) + + await waitFor(() => { expect(discover).toHaveBeenCalled() }) + expect(firstProbe(discover)).toMatchObject({ apiKey: 'sk-abc' }) + }) }) From a89c26b6110420ff59582528839d01230c970e9a Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Thu, 6 Aug 2026 22:42:56 +0800 Subject: [PATCH 43/67] test(web): pin the API key field refusal end to end --- ...-08-06-api-key-format-validation.i18n.yaml | 6 + .../2026-08-06-api-key-format-validation.md | 105 ++++++++++++++++++ ...2026-08-06-api-key-format-validation.zh.md | 105 ++++++++++++++++++ ...-08-06-api-key-format-validation.i18n.yaml | 6 - .../2026-08-06-api-key-format-validation.md | 101 ----------------- ...2026-08-06-api-key-format-validation.zh.md | 101 ----------------- apps/web/tests/models-settings.e2e.ts | 19 ++++ 7 files changed, 235 insertions(+), 208 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md delete mode 100644 .agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml delete mode 100644 .agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.md delete mode 100644 .agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml new file mode 100644 index 0000000000..42b42a591a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md +2026-08-06-api-key-format-validation.md: 9ec247cb2ba2578158759ec1115c5d3a95778cc4 +2026-08-06-api-key-format-validation.zh.md: 63c6a8c17ee93b4b68eb3505d5499756e9fb2401 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md new file mode 100644 index 0000000000..9ec247cb2b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md @@ -0,0 +1,105 @@ +# Agent Note: Validate API key format before it reaches an HTTP header + +Status: implemented + +English | [中文](2026-08-06-api-key-format-validation.zh.md) + +## Problem + +An API key holding characters no HTTP header value can carry was accepted by every configuration surface and failed only when a request was built, far from the field that caused it. + +Pasting a key containing an emoji, CJK text, or a full-width punctuation mark into the web Models page reported a successful save. The first turn then failed with `Cannot convert argument to a ByteString because the character at index 7 has a value of 55357 which is greater than 255` — the index and code point are UTF-16 internals with no action attached, and they disclose the code point of one character of the key. `llm-deepseek` produced this because `fetch` builds the `Bearer` header inside the `try` in [adapter.ts](../../../../packages/llm/llm-deepseek/src/adapter.ts), whose `catch` labels every failure `TRANSPORT`; that label is in `DEFAULT_RETRYABLE_CODES`, so a permanent, deterministic fault was also retried three times. + +`llm-pi-ai` was worse on the same input. Its discovery probe builds the same header with a bare `fetch` in [discovery.ts](../../../../packages/llm/llm-pi-ai/src/discovery.ts) and wrapped every failure as `could not reach <url>`, so a local key fault was reported as an unreachable network. The probe is reachable from the unsaved draft: `ProviderEditor` puts the typed `keyDraft` into its probe request, so the model-listing button sent an illegal key before anything was stored. + +Whitespace passed every check. `ProviderEditor` tested `keyDraft.length` and `resolveAdapterOptions` tested `config.apiKey.length`, so a key of three spaces stored and then authenticated as `Bearer` plus blanks. `llm-pi-ai` rejected an empty literal `apiKey` in `resolveProfiles`, but applied no check whatsoever to a credential- or environment-sourced key — the path the Models page writes, and therefore the path users actually take. + +Sources: deepseek-harness#1594 and #1595; dsh-external#247, #249, #266, and #210. + +## Decision + +One rule defines a legal key: **after trimming, non-empty, and every character within `[\x21-\x7E]`** — printable ASCII, space excluded. + +This single predicate covers every input the sources list: empty, leading and trailing whitespace, interior whitespace, C0 control characters, emoji, CJK text, and full-width punctuation. It is also exactly the constraint that produced the ByteString failure, so the two issues close on one definition rather than on two coincidentally related fixes. + +A second, narrower rule catches a pasted environment line: input matching `^[A-Z][A-Z0-9_]*=` or wrapped in matching quotes is refused. Restricting the prefix to upper-case keeps real keys clear of it — `sk-` forms break the identifier match at the hyphen. + +### Invariants belong at every layer; heuristics belong where the human is + +The charset rule is an invariant. A non-ASCII character *cannot* travel in a header value for any provider, so enforcing it in the browser, in each resolver, and on every credential read is consistent by construction rather than by agreement. + +The shape rule is a guess about how people paste, so it runs **only in the browser**. `llm-pi-ai` fronts OpenAI, Anthropic, and arbitrary hand-declared gateways whose key formats this repository does not own; a gateway issuing a key shaped like `TENANT1=abc` would, if the rule ran in the resolver, be locked out with no escape — the settings page would refuse it and a hand-written `.env` would be rejected on read. Confining the heuristic to the surface where the paste happens keeps the environment as the way through. + +### Absence is a configuration state, not a missing key + +"No API key" means three different things here, and only one of them is an error. The rule applies to a value that was *provided*; deciding whether one was provided at all stays with each caller. + +**Omitted.** A profile naming neither `apiKey` nor `apiKeyEnv` is authenticated by something other than a harness-held key. `routeAuth` in [provider.ts](../../../../packages/llm/llm-pi-ai/src/provider.ts) keeps the installed catalog provider's own auth precisely so provider-native ambient discovery survives, and `openai-codex` — shipped in that catalog — authenticates through OAuth and refuses an explicit key outright. `namesCredential` carries this distinction. In `llm-deepseek`, an absent `apiKey` likewise falls through to `apiKeyEnv`. Omission is never validated. + +**A blank field in the web UI.** The key input opens empty even for a provider whose key is already stored — the `keyStored` copy reads "Configured — enter a new value to replace" — so blank means *keep what is stored*. `ProviderEditor` skips `credentials.set` entirely when the draft is empty, and that stays a no-op: a blank field never blocks submit, or editing a base URL would demand re-entering the key. + +**Provided, but empty or whitespace-only.** This is the one error, because the user expressed an intent to set a key and supplied nothing. `llm-pi-ai` already worded it correctly in `resolveProfiles` — *has an empty apiKey; omit it to use ambient authentication* — and that shape, naming the legitimate alternative rather than just refusing, is what the other surfaces adopt. + +`normalizeApiKey` therefore takes `string`, never `string | undefined`. + +### Where the rule lives + +`normalizeApiKey` is a module of the `dsh-llm` seam, beside [attribution.ts](../../../../packages/llm/llm/src/attribution.ts), which already owns shared header concerns. Both adapters depend on the seam and both need the rule, so it has two current consumers rather than a speculative one. It returns the trimmed value or a reason (`empty`, `illegalCharacters`). + +Both adapters also need the identical "refuse a stored credential" diagnosis, differing only by package prefix. `LlmError` is declared in the seam's `index.ts`, so `assertUsableApiKey(raw, pkg, ref)` lives there beside it and neither adapter carries a local copy. The predicate module stays dependency-free: importing `LlmError` into `api-key.ts` would cycle with `index.ts`'s re-export of it. + +The client cannot import any of this: client packages reference only client packages, so `packages/client/ui-models` mirrors the predicate in its own `apiKey.ts` and owns the localized messages, exactly as `validateDeepSeekModels` mirrors the host's `catalogModel` schema. Each side names the other in a comment. + +### What each surface does + +| Surface | Behavior | +|---|---| +| `dsh-llm` | Owns `normalizeApiKey`, `assertUsableApiKey`, and `INVALID_CREDENTIAL_CODE`, which is deliberately outside `DEFAULT_RETRYABLE_CODES`. | +| `llm-deepseek` `resolveAdapterOptions` | Normalizes a present `apiKey`, throwing beside the other beyond-schema bounds; uses the trimmed value. An absent one falls through to `apiKeyEnv`. | +| `llm-deepseek` `resolveApiKey` | Normalizes what the credentials seam or environment returns, rejecting with `INVALID_CREDENTIAL` naming the Models page and never echoing the key. | +| `llm-pi-ai` `resolveProfiles` | Applies the shared rule, keeping its "omit it to use ambient authentication" wording, and writes the trimmed value into the resolved profile. | +| `llm-pi-ai` `resolveApiKey` | Normalizes the credential and environment paths. A profile naming no credential still returns `undefined`, so ambient and OAuth routes are unaffected. | +| `llm-pi-ai` `discoverModels` | Normalizes before building the header, so an illegal key is a credential fault rather than an unreachable endpoint. A probe carrying no key stays unauthenticated. | +| `ui-models` | Mirrors the charset rule, adds the shape heuristic, trims `keyDraft` before probe and `credentials.set`, and fixes the `stringAt` emptiness test. A blank field remains a no-op that submits; a field holding only whitespace is a field-level failure. Submit is gated and the failure renders on the field, matching the existing `modelFailure` pattern. | + +`ProviderEditor` serves both the DeepSeek and pi-ai layouts, so one client change covers both providers. `CustomProviderCard` carries the same judgement for a hand-declared route. + +`credentials-local` is deliberately untouched. It stores credentials generally, and printable-ASCII is a constraint of HTTP headers rather than of credential storage; its existing refusal of values no dotenv style can represent stands as it was. + +## Alternatives considered + +**A `.pattern()` on the `apiKey` schema field.** Vendored schemastery supports it, and the pattern would serialize to the browser with the rest of the namespace schema — one rule, delivered rather than mirrored. It lost because a pattern cannot trim first: `cordis.yml` would then reject a padded key while `.env` tolerated one, and the resolver would disagree with the schema about the same string. Validating in `resolveAdapterOptions` keeps every surface trim-then-validate, and that function is already where this package re-judges bounds the schema cannot express. + +**A validation module shared by client and host.** Rejected by the source-plane layout: client packages reference only client packages plus `vendor/cordis` and `support/invariants`, and widening that to reach a host package would collide the two `Context` merges the split exists to keep apart. Mirroring a one-line predicate with a test on each side is the established shape here. + +**A per-adapter thrower in each of `llm-deepseek` and `llm-pi-ai`.** The first plan gave each adapter its own, differing only by the package prefix in the message, with a duplication-gate exemption to excuse the pair. Rejected before implementation: `LlmError` is declared in the seam, so the seam can own the diagnosis outright, and an exemption there would have hidden exactly the duplication it was covering for. + +**Sniffing the `TypeError` in the adapter's `catch`.** This would classify the ByteString failure after the fact, leaving the header construction itself unguarded. It depends on the wording of a Node error message, so it degrades silently across runtime versions, and it cannot help `llm-pi-ai`, whose request header is built inside the pi-ai SDK. Refusing the key before handing it over works for both adapters and for the discovery probe. + +**Enforcing in `credentials-local.set`.** It would catch every writer at once, including a hand-edited file. It lost because that provider stores credentials of every kind, and a rule derived from HTTP header encoding does not belong to it. + +**Running the shape heuristic in the resolvers too.** Symmetric, and it would stop a pasted environment line written directly into `.env`. Rejected for the lockout described above: a false positive in a resolver leaves the user no working path, while a false positive in the browser leaves the environment open. + +**Probing the provider at save time to prove the key works.** It would close the complaint the sources actually open with — a save that reports success and fails at the first turn. Rejected as out of scope and, on the code as it stood, unbuildable: `discoverModels` short-circuits to the installed catalog before any network call for exactly the providers pi-ai ships catalogs for, so it verified nothing about the key, and the DeepSeek card has no probe at all. A verifier's value is distinguishing "key rejected" from "cannot reach", which is the distinction this change makes reliable; building it first would have produced a verifier unable to tell its own outcomes apart. Comparable products also do not verify on save, so a blocking network call there would be an unexpected behavior rather than a missing one. + +## Consequences + +A malformed key is refused at the field that holds it, and a malformed stored key fails as `INVALID_CREDENTIAL` with a message naming where to fix it and no fragment of the key. Because that code sits outside `DEFAULT_RETRYABLE_CODES`, a deterministic credential fault is no longer retried three times as a transport blip. `llm-pi-ai` discovery reports an illegal probe key as a credential fault instead of an unreachable endpoint. + +The shape heuristic can refuse a real key. Upper-case-identifier-then-`=` and matched surrounding quotes are shapes no known provider issues, and the rule runs only in the browser, so a user who hits it can still set the credential through the environment. The residual cost is a confusing refusal for a key nobody has yet reported. + +Restricting to printable ASCII is stricter than the transport requires: a header value may carry `\x80`–`\xFF`. Admitting latin-1 would let `é` through to return an opaque 401 instead of a local, explained refusal, so the stricter rule is deliberate. A provider that issues latin-1 keys would need this rule widened. + +The charset predicate exists twice, once per source plane. The layout forbids sharing it; each side carries its own test and names its twin. + +Keys already stored by an earlier build are read through `resolveApiKey`, so an illegal stored value fails at resolution rather than at request time. The diagnosis improves, but the failure moves earlier for anyone currently holding one. + +The costliest way to get this wrong would have been to treat absence as invalidity: a rule applied to `undefined` breaks every route authenticating through ambient discovery or OAuth, and a blank field that blocked submit makes editing any other setting demand re-entering the key. Both are pinned by tests rather than left to care. + +## Testing + +`packages/llm/llm/tests/api-key.spec.ts` drives `normalizeApiKey` and `assertUsableApiKey` over the whole input table — empty, whitespace-only, padded, interior-space, C0 control, emoji, CJK, full-width, latin-1, and the printable-ASCII boundary — and pins that a refusal carries `INVALID_CREDENTIAL` and no part of the key. + +`packages/llm/llm-deepseek/tests/` covers the literal-config path in `adapter.spec.ts` and the stored-credential path end to end in `dynamic-config.spec.ts`, through the real credentials seam rather than a stub. `packages/llm/llm-pi-ai/tests/` covers `resolveProfiles` — including that the trimmed value reaches the resolved profile, which the `...rest` spread would otherwise discard — and the discovery probe, including that a probe with no key sends no `authorization` header. + +`packages/client/ui-models/tests/` pins `apiKeyFailure` over the same table plus the paste-shape cases, and drives both cards: a blank field submits without writing a credential, a whitespace-only field fails on the field, an illegal or wrapped key blocks submit, a padded key is trimmed before `credentials.set` and before an interrogation, and a hand-declared route can be created with no key at all. diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md new file mode 100644 index 0000000000..63c6a8c17e --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md @@ -0,0 +1,105 @@ +# Agent Note: 在 API Key 进入 HTTP header 之前校验其格式 + +Status: implemented + +[English](2026-08-06-api-key-format-validation.md) | 中文 + +## Problem + +一个含有 HTTP header value 无法承载的字符的 API Key,曾被每一层配置界面接受,直到构造请求时才失败——离引发它的那个字段已经很远。 + +把含 emoji、中文或全角标点的 Key 粘进 Web 模型设置页,保存会报成功。第一轮对话随即失败于 `Cannot convert argument to a ByteString because the character at index 7 has a value of 55357 which is greater than 255`——其中的下标与码点是 UTF-16 内部细节,不附带任何可执行动作,却泄露了 Key 中某一个字符的码点。`llm-deepseek` 之所以产出这句,是因为 `fetch` 在 [adapter.ts](../../../../packages/llm/llm-deepseek/src/adapter.ts) 的 `try` 内部构造 `Bearer` header,而那个 `catch` 把一切失败都标为 `TRANSPORT`;该标签又在 `DEFAULT_RETRYABLE_CODES` 之中,于是一个永久且确定的故障还会被重试三次。 + +同样的输入在 `llm-pi-ai` 上更糟。它的探测路径在 [discovery.ts](../../../../packages/llm/llm-pi-ai/src/discovery.ts) 里用裸 `fetch` 构造同一个 header,并把一切失败包装成 `could not reach <url>`,于是一个本地的 Key 故障被报成网络不可达。这条探测在保存之前就够得着:`ProviderEditor` 把用户输入的 `keyDraft` 直接放进探测请求,所以「获取模型列表」按钮会在任何东西落盘之前就把非法 Key 发出去。 + +空白字符能通过每一道检查。`ProviderEditor` 判的是 `keyDraft.length`,`resolveAdapterOptions` 判的是 `config.apiKey.length`,于是三个空格构成的 Key 会被存下,随后以 `Bearer` 加若干空格去认证。`llm-pi-ai` 在 `resolveProfiles` 中拒绝空的字面量 `apiKey`,却对来自凭据或环境的 Key 完全不做检查——而那正是模型设置页写入的路径,也就是用户真正走的路径。 + +来源:deepseek-harness#1594 与 #1595;dsh-external#247、#249、#266、#210。 + +## Decision + +一条规则定义什么是合法 Key:**trim 之后非空,且每个字符都落在 `[\x21-\x7E]`**——可打印 ASCII,不含空格。 + +这一个断言覆盖了来源列出的全部输入:空值、首尾空白、中间空白、C0 控制字符、emoji、中文、全角标点。它同时正是造成 ByteString 失败的那条约束,所以两个 issue 收敛于同一个定义,而不是两个恰好相关的修复。 + +第二条更窄的规则用于识别整行粘贴的环境变量:匹配 `^[A-Z][A-Z0-9_]*=` 或首尾成对引号的输入会被拒绝。把前缀限定为全大写可以让真实 Key 与之绝缘——`sk-` 这类形态会在连字符处中断标识符匹配。 + +### 不变量属于每一层,启发式属于人所在的那一层 + +字符集规则是不变量。非 ASCII 字符对任何 provider 都**不可能**在 header value 中传输,因此在浏览器、在各个 resolver、在每一次凭据读取上执行它,是结构上的一致而非约定上的一致。 + +形状规则是对人如何粘贴的猜测,因此**只在浏览器中运行**。`llm-pi-ai` 前面挂着 OpenAI、Anthropic 以及任意手工声明的网关,本仓库并不掌握它们的 Key 格式;若这条规则运行在 resolver 中,一个签发形如 `TENANT1=abc` 的网关会让用户被彻底锁死、无路可走——设置页拒绝它,手写的 `.env` 在读取时同样被拒。把启发式限制在粘贴动作发生的那一层,环境变量便始终是那条出路。 + +### 「没有 Key」是一种配置状态,不是缺失 + +在这里,「没有 API Key」意味着三件完全不同的事,其中只有一件是错误。规则作用于**已提供**的值;至于究竟有没有提供,由各个调用方自行判断。 + +**未指定。** 既不写 `apiKey` 也不写 `apiKeyEnv` 的 profile,是由 harness 所持有的 Key 之外的东西来鉴权的。[provider.ts](../../../../packages/llm/llm-pi-ai/src/provider.ts) 中的 `routeAuth` 保留内置 catalog provider 自身的鉴权,正是为了让 provider 原生的 ambient 发现得以存活;而该 catalog 附带的 `openai-codex` 通过 OAuth 鉴权,并会直接拒绝一个显式的 Key。`namesCredential` 承载着这一区分。在 `llm-deepseek` 中,缺省的 `apiKey` 同样会回落到 `apiKeyEnv`。未指定的情形永不参与校验。 + +**Web UI 中留空的输入框。** 即便某个 provider 的 Key 已经存好,该输入框也是空着打开的——`keyStored` 的文案写的是「已配置——输入新值以替换」——所以留空意味着*保持已存储的值*。`ProviderEditor` 在草稿为空时完全跳过 `credentials.set`,这一点保持不变:留空绝不拦截提交,否则改一个 base URL 都得重新输一遍 Key。 + +**已提供,但为空或纯空白。** 这是唯一的错误,因为用户表达了设置 Key 的意图却什么都没给。`llm-pi-ai` 在 `resolveProfiles` 中的措辞本就是对的——*has an empty apiKey; omit it to use ambient authentication*——这种指明合法替代路径而非单纯拒绝的形态,正是其他界面所采用的。 + +因此 `normalizeApiKey` 接受 `string`,而绝非 `string | undefined`。 + +### 规则住在哪里 + +`normalizeApiKey` 是 `dsh-llm` seam 的一个模块,与已经承担共享 header 事务的 [attribution.ts](../../../../packages/llm/llm/src/attribution.ts) 并列。两个适配器都依赖该 seam 且都需要这条规则,因此它拥有两个当前消费者而非一个预设消费者。它返回 trim 后的值,或一个原因(`empty`、`illegalCharacters`)。 + +两个适配器同样都需要那句完全相同的「拒绝一个已存储凭据」的诊断,差别仅在包名前缀。`LlmError` 声明在 seam 的 `index.ts` 中,因此 `assertUsableApiKey(raw, pkg, ref)` 就住在它旁边,两个适配器都不再各留一份。断言模块本身保持零依赖:把 `LlmError` 引入 `api-key.ts` 会与 `index.ts` 对它的再导出成环。 + +客户端无法引入其中任何一个:client 包只 reference client 包,因此 `packages/client/ui-models` 在自己的 `apiKey.ts` 中镜像这个断言并持有本地化文案,正如 `validateDeepSeekModels` 镜像 host 侧的 `catalogModel` schema。两侧在注释中互相指名。 + +### 各个界面各做什么 + +| 界面 | 行为 | +|---|---| +| `dsh-llm` | 拥有 `normalizeApiKey`、`assertUsableApiKey` 与 `INVALID_CREDENTIAL_CODE`,后者刻意不进 `DEFAULT_RETRYABLE_CODES`。 | +| `llm-deepseek` `resolveAdapterOptions` | 归一化已提供的 `apiKey`,与其他超出 schema 的边界检查并排抛错;使用 trim 后的值。缺省的 `apiKey` 回落到 `apiKeyEnv`。 | +| `llm-deepseek` `resolveApiKey` | 归一化凭据 seam 或环境返回的值,以 `INVALID_CREDENTIAL` 拒绝,消息指明模型设置页,绝不回显 Key。 | +| `llm-pi-ai` `resolveProfiles` | 施加这条共享规则,保留其「omit it to use ambient authentication」的措辞,并把 trim 后的值写进解析后的 profile。 | +| `llm-pi-ai` `resolveApiKey` | 归一化凭据与环境路径。不指定任何凭据的 profile 仍返回 `undefined`,ambient 与 OAuth 路由不受影响。 | +| `llm-pi-ai` `discoverModels` | 在构造 header 之前归一化,使非法 Key 成为凭据故障而非端点不可达。不带 Key 的探测保持未鉴权。 | +| `ui-models` | 镜像字符集规则,加入形状启发式,在探测与 `credentials.set` 之前 trim `keyDraft`,并修正 `stringAt` 的空值判断。留空的输入框仍是可以提交的空操作;只含空白的输入框则是字段级失败。提交受拦截,失败呈现在字段上,与既有的 `modelFailure` 模式一致。 | + +`ProviderEditor` 同时服务 DeepSeek 与 pi-ai 两种布局,因此一处客户端改动覆盖两个 provider。`CustomProviderCard` 为手工声明的路由承载同一套判定。 + +`credentials-local` 刻意不动。它存储各类凭据,而可打印 ASCII 是 HTTP header 的约束而非凭据存储的约束;它既有的、拒绝任何 dotenv 样式都无法表示的值的行为保持原样。 + +## Alternatives considered + +**在 `apiKey` schema 字段上加 `.pattern()`。** vendor 中的 schemastery 支持它,且该 pattern 会随命名空间 schema 一同序列化到浏览器——一条规则,投递而非镜像。它落败于 pattern 无法先行 trim:那样 `cordis.yml` 会拒绝带首尾空白的 Key 而 `.env` 却容忍,resolver 与 schema 会对同一个字符串给出分歧。在 `resolveAdapterOptions` 中校验可以让每一层都是 trim-then-validate,而该函数本就是本包重新裁定 schema 无法表达的边界之处。 + +**由 client 与 host 共享一个校验模块。** 被 source plane 布局否决:client 包只 reference client 包外加 `vendor/cordis` 与 `support/invariants`,把它放宽到够得着 host 包会撞上这一分割本就要隔开的两份 `Context` 合并。在两侧各镜像一行断言并各配一份测试,是此处的既定形态。 + +**在 `llm-deepseek` 与 `llm-pi-ai` 中各留一个抛错 helper。** 最初的计划正是各留一份,差别仅在消息中的包名前缀,并配一个重复检测豁免来放行这一对。在实现之前即被否决:`LlmError` 声明在 seam 中,因此 seam 完全可以自己拥有这句诊断,而那里的一个豁免恰恰会掩盖它本要遮掩的重复。 + +**在适配器的 `catch` 中嗅探 `TypeError`。** 这只是事后归类 ByteString 失败,header 构造本身仍无防护。它依赖 Node 错误消息的措辞,因而会随运行时版本静默失效;它也帮不到 `llm-pi-ai`——后者的请求 header 构造在 pi-ai SDK 内部。在交出 Key 之前就拒绝,则对两个适配器与探测路径同时有效。 + +**在 `credentials-local.set` 中执行。** 它能一次性拦住所有写入方,包括手工编辑的文件。它落败于该 provider 存储各种类型的凭据,而一条源自 HTTP header 编码的规则并不属于它。 + +**让形状启发式也在 resolver 中运行。** 更对称,且能拦住直接写进 `.env` 的整行环境变量。因上文所述的锁死风险而否决:resolver 中的一次误判会让用户无路可走,浏览器中的一次误判则仍留有环境变量这条路。 + +**在保存时探测 provider 以证明 Key 可用。** 它能关掉来源真正开篇抱怨的那件事——保存报成功、第一轮才失败。因超出范围而否决,且在当时的代码上无法建成:对 pi-ai 恰好自带 catalog 的那些 provider,`discoverModels` 会在任何网络调用之前短路到内置 catalog,因而对 Key 什么都验证不了;而 DeepSeek 卡片根本没有探测。验证器的价值在于分清「Key 被拒」与「无法连通」,而这正是本次改动让其变得可靠的区分;先建验证器只会得到一个分不清自身结果的验证器。同类产品也不在保存时验证,因此保存时的阻断式网络调用会是一个意外行为,而非一处缺失。 + +## Consequences + +格式错误的 Key 在持有它的那个字段上就被拒绝;格式错误的已存储 Key 以 `INVALID_CREDENTIAL` 失败,消息指明修复位置且不含 Key 的任何片段。由于该 code 位于 `DEFAULT_RETRYABLE_CODES` 之外,一个确定性的凭据故障不再被当作瞬时传输抖动重试三次。`llm-pi-ai` 的探测把非法 Key 报为凭据故障,而非端点不可达。 + +形状启发式可能拒绝一个真实的 Key。全大写标识符接 `=`、以及首尾成对引号,都是已知 provider 不会签发的形态,且该规则只在浏览器中运行,因此撞上它的用户仍可通过环境变量设置该凭据。残留代价是对一个尚无人报告过的 Key 给出一次令人困惑的拒绝。 + +限定为可打印 ASCII 比传输本身的要求更严:header value 是可以承载 `\x80`–`\xFF` 的。放行 latin-1 会让 `é` 通过并换回一个语焉不详的 401,而不是一次本地的、有解释的拒绝,因此从严是刻意的。若某个 provider 签发 latin-1 的 Key,这条规则需要放宽。 + +字符集断言存在两份,每个 source plane 一份。布局禁止共享它;两侧各自带测试并在注释中指名其孪生体。 + +早先版本已存下的 Key 会经 `resolveApiKey` 读取,因此一个非法的既存值将从解析时开始失败,而非到请求时才失败。诊断变好了,但对当前正持有这类值的人而言,失败点提前了。 + +把这件事做错的最大代价,会是把「未指定」当成「非法」:一条施加到 `undefined` 上的规则会打断每一条依赖 ambient 发现或 OAuth 鉴权的路由,而一个会拦截提交的空输入框,则会让改动任何其他设置都必须重新输入 Key。这两点都由测试钉住,而不是仅仰赖谨慎。 + +## Testing + +`packages/llm/llm/tests/api-key.spec.ts` 以整张输入表驱动 `normalizeApiKey` 与 `assertUsableApiKey`——空值、纯空白、带首尾空白、含中间空格、C0 控制字符、emoji、中文、全角、latin-1,以及可打印 ASCII 的边界字符——并钉住一次拒绝携带 `INVALID_CREDENTIAL` 且不含 Key 的任何部分。 + +`packages/llm/llm-deepseek/tests/` 在 `adapter.spec.ts` 中覆盖字面量配置路径,在 `dynamic-config.spec.ts` 中经真实凭据 seam(而非 stub)端到端覆盖已存储凭据路径。`packages/llm/llm-pi-ai/tests/` 覆盖 `resolveProfiles`——包括 trim 后的值确实到达解析后的 profile,否则会被 `...rest` 展开丢弃——以及探测路径,包括不带 Key 的探测不会发出 `authorization` 标头。 + +`packages/client/ui-models/tests/` 以同一张表加上形状用例钉住 `apiKeyFailure`,并驱动两张卡片:留空的输入框可提交且不写入凭据、只含空白的输入框在字段上失败、非法或被包裹的 Key 拦截提交、带首尾空白的 Key 在 `credentials.set` 与探测之前被 trim,以及手工声明的路由可以完全不带 Key 创建。 diff --git a/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml b/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml deleted file mode 100644 index f62a18e0eb..0000000000 --- a/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.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/bug-fix/2026-08-06-api-key-format-validation.md -2026-08-06-api-key-format-validation.md: dc19baa8b697998df2892f0840a35a8232cc92de -2026-08-06-api-key-format-validation.zh.md: 28073660b1d4868fecf5ce419726d6d997383392 diff --git a/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.md b/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.md deleted file mode 100644 index dc19baa8b6..0000000000 --- a/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.md +++ /dev/null @@ -1,101 +0,0 @@ -# Agent Note: Validate API key format before it reaches an HTTP header - -Status: proposed - -English | [中文](2026-08-06-api-key-format-validation.zh.md) - -## Problem - -An API key holding characters no HTTP header value can carry is accepted by every configuration surface and fails only when a request is built, far from the field that caused it. - -Paste a key containing an emoji, CJK text, or a full-width punctuation mark into the web Models page and the save reports success. The first turn then fails with `Cannot convert argument to a ByteString because the character at index 7 has a value of 55357 which is greater than 255` — the index and code point are UTF-16 internals with no action attached, and they disclose the code point of one character of the key. `llm-deepseek` produces this because `fetch` builds the `Bearer` header inside the `try` at [adapter.ts](../../../../packages/llm/llm-deepseek/src/adapter.ts), whose `catch` labels every failure `TRANSPORT`; that label is in `DEFAULT_RETRYABLE_CODES`, so a permanent, deterministic fault is also retried three times. - -`llm-pi-ai` is worse on the same input. Its discovery probe builds the same header with a bare `fetch` in [discovery.ts](../../../../packages/llm/llm-pi-ai/src/discovery.ts) and wraps every failure as `could not reach <url>`, so a local key fault is reported as an unreachable network. The probe is reachable from the unsaved draft: `ProviderEditor` puts the typed `keyDraft` into its probe request, so the model-listing button sends an illegal key before anything is stored. - -Whitespace passes every check. `ProviderEditor` tests `keyDraft.length` and `resolveAdapterOptions` tests `config.apiKey.length`, so a key of three spaces stores and then authenticates as `Bearer` plus blanks. `llm-pi-ai` rejects an empty literal `apiKey` in `resolveProfiles`, but applies no check whatsoever to a credential- or environment-sourced key — which is the path the Models page writes, and therefore the path users actually take. - -Sources: deepseek-harness#1594 and #1595; dsh-external#247, #249, #266, and #210. - -## Proposal - -One rule defines a legal key: **after trimming, non-empty, and every character within `[\x21-\x7E]`** — printable ASCII, space excluded. - -This single predicate covers every input the sources list: empty, leading and trailing whitespace, interior whitespace, C0 control characters, emoji, CJK text, and full-width punctuation. It is also exactly the constraint that produced the ByteString failure, so the two issues close on one definition rather than on two coincidentally related fixes. - -A second, narrower rule catches a pasted environment line: reject input matching `^[A-Z][A-Z0-9_]*=` or wrapped in matching quotes. Restricting the prefix to upper-case keeps real keys clear of it — `sk-` forms break the identifier match at the hyphen. - -### Invariants belong at every layer; heuristics belong where the human is - -The charset rule is an invariant. A non-ASCII character *cannot* travel in a header value for any provider, so enforcing it in the browser, in each resolver, and on every credential read is consistent by construction rather than by agreement. - -The shape rule is a guess about how people paste, so it runs **only in the browser**. `llm-pi-ai` fronts OpenAI, Anthropic, and arbitrary hand-declared gateways whose key formats this repository does not own; a gateway issuing a key shaped like `TENANT1=abc` would, if the rule ran in the resolver, be locked out with no escape — the settings page would refuse it and a hand-written `.env` would be rejected on read. Confining the heuristic to the surface where the paste happens keeps the environment as the way through. - -### Absence is a configuration state, not a missing key - -"No API key" means three different things here, and only one of them is an error. The rule applies to a value that was *provided*; deciding whether one was provided at all stays with each caller. - -**Omitted.** A profile naming neither `apiKey` nor `apiKeyEnv` is authenticated by something other than a harness-held key. `routeAuth` in [provider.ts](../../../../packages/llm/llm-pi-ai/src/provider.ts) keeps the installed catalog provider's own auth precisely so provider-native ambient discovery survives, and `openai-codex` — shipped in that catalog — authenticates through OAuth and refuses an explicit key outright. `namesCredential` exists to carry this distinction. In `llm-deepseek`, an absent `apiKey` likewise falls through to `apiKeyEnv`. Omission is never validated. - -**A blank field in the web UI.** The key input opens empty even for a provider whose key is already stored — the `keyStored` copy reads "Configured — enter a new value to replace" — so blank means *keep what is stored*. `ProviderEditor` already skips `credentials.set` entirely when the draft is empty, and that stays a no-op: a blank field must never block submit, or editing a base URL would demand re-entering the key. - -**Provided, but empty or whitespace-only.** This is the one error, because the user expressed an intent to set a key and supplied nothing. `llm-pi-ai` already words it correctly in `resolveProfiles` — *has an empty apiKey; omit it to use ambient authentication* — and that shape, naming the legitimate alternative rather than just refusing, is what the other surfaces adopt. - -`normalizeApiKey` therefore takes `string`, never `string | undefined`. - -### Where the rule lives - -`normalizeApiKey` is a new module of the `dsh-llm` seam, beside [attribution.ts](../../../../packages/llm/llm/src/attribution.ts), which already owns shared header concerns. Both adapters depend on the seam and both need the rule, so it has two current consumers rather than a speculative one. It returns the trimmed value or a reason (`empty`, `illegalCharacters`). - -The client cannot import it: client packages reference only client packages, so `packages/client/ui-models` mirrors the predicate and owns the localized messages, exactly as `validateDeepSeekModels` mirrors the host's `catalogModel` schema today. Each side names the other in a comment. - -### What each surface does - -| Surface | Change | -|---|---| -| `dsh-llm` | Add `normalizeApiKey`; add `INVALID_CREDENTIAL`, deliberately outside `DEFAULT_RETRYABLE_CODES`. | -| `llm-deepseek` `resolveAdapterOptions` | Normalize a present `apiKey`, throwing beside the existing beyond-schema bounds; use the trimmed value. An absent one still falls through to `apiKeyEnv`. Closes dsh-external#210. | -| `llm-deepseek` `resolveApiKey` | Normalize what the credentials seam or environment returns; reject with `INVALID_CREDENTIAL` naming the Models page, never echoing the key. | -| `llm-pi-ai` `resolveProfiles` | Widen the existing emptiness check to the shared rule, keeping its "omit it to use ambient authentication" wording. | -| `llm-pi-ai` `resolveApiKey` | Normalize the credential and environment paths, which are unchecked today. A profile naming no credential still returns `undefined` untouched, so ambient and OAuth routes are unaffected. | -| `llm-pi-ai` `discoverModels` | Normalize before building the header, so an illegal key stops reporting as an unreachable endpoint. A probe carrying no key stays unauthenticated as it is today. | -| `ui-models` | Mirror the charset rule, add the shape heuristic, trim `keyDraft` before probe and `credentials.set`, and fix the `stringAt` emptiness test. A blank field remains a no-op that submits; a field holding only whitespace is a field-level failure, so typed input is never silently discarded. Gate submit and show the failure on the field, matching the existing `modelFailure` pattern. | - -`ProviderEditor` serves both the DeepSeek and pi-ai layouts, so one client change covers both providers. - -`credentials-local` is deliberately untouched. It stores credentials generally, and printable-ASCII is a constraint of HTTP headers rather than of credential storage; its existing refusal of values no dotenv style can represent stays as it is. - -## Alternatives considered - -**A `.pattern()` on the `apiKey` schema field.** Vendored schemastery supports it, and the pattern would serialize to the browser with the rest of the namespace schema — one rule, delivered rather than mirrored. It loses because a pattern cannot trim first: `cordis.yml` would then reject a padded key while `.env` tolerated one, and the resolver would disagree with the schema about the same string. Validating in `resolveAdapterOptions` keeps every surface trim-then-validate, and that function is already where this package re-judges bounds the schema cannot express. - -**A validation module shared by client and host.** Rejected by the source-plane layout: client packages reference only client packages plus `vendor/cordis` and `support/invariants`, and widening that to reach a host package would collide the two `Context` merges the split exists to keep apart. Mirroring a one-line predicate with a test on each side is the established shape here. - -**Sniffing the `TypeError` in the adapter's `catch`.** This would classify the ByteString failure after the fact, leaving the header construction itself unguarded. It depends on the wording of a Node error message, so it degrades silently across runtime versions, and it cannot help `llm-pi-ai`, whose header is built inside the pi-ai SDK. Refusing the key before handing it over works for both adapters and for the discovery probe. - -**Enforcing in `credentials-local.set`.** It would catch every writer at once, including a hand-edited file. It loses because that provider stores credentials of every kind, and a rule derived from HTTP header encoding does not belong to it. - -**Running the shape heuristic in the resolvers too.** Symmetric, and it would stop a pasted environment line written directly into `.env`. Rejected for the lockout described above: a false positive in a resolver leaves the user no working path, while a false positive in the browser leaves the environment open. - -**Probing the provider at save time to prove the key works.** It would close the complaint the sources actually open with — a save that reports success and fails at the first turn. Rejected as out of scope and, on today's code, unbuildable: `discoverModels` short-circuits to the installed catalog before any network call for exactly the providers pi-ai ships catalogs for, so it verifies nothing about the key, and the DeepSeek card has no probe at all. A verifier's value is distinguishing "key rejected" from "cannot reach", which is the distinction this note makes reliable; building it first would produce a verifier unable to tell its own outcomes apart. Comparable products also do not verify on save, so a blocking network call at save time would be an unexpected behavior rather than a missing one. - -## Acceptance criteria - -- The browser, both resolvers, and both credential reads accept and reject the same *provided* strings: whitespace-only, padded, interior-space, C0 control, emoji, CJK, and full-width inputs are refused; a printable-ASCII key is accepted, trimmed. -- A profile naming no credential still resolves to no key, and a route authenticating through the installed provider's own ambient discovery or OAuth keeps working untouched. -- A blank key field saves the rest of the card without writing a credential; a field holding only whitespace fails on the field instead of being silently dropped. -- A rejected key names the API key field in the web UI and blocks submit; nothing is written to settings or credentials. -- A key that reaches a resolver illegally fails as `INVALID_CREDENTIAL` with a message naming where to fix it, containing no part of the key, and is not retried. -- `llm-pi-ai` discovery reports an illegal key as a key fault, not as an unreachable endpoint. -- A legal key still travels the existing `credentials.set` path unchanged. - -## Risks - -The shape heuristic can refuse a real key. Upper-case-identifier-then-`=` and matched surrounding quotes are shapes no known provider issues, and the rule runs only in the browser, so a user who hits it can still set the credential through the environment. The residual cost is a confusing refusal for a key nobody has yet reported. - -Restricting to printable ASCII is stricter than the transport requires: a header value may carry `\x80`–`\xFF`. Admitting latin-1 would let `é` through to return an opaque 401 instead of a local, explained refusal, so the stricter rule is deliberate. A provider that issues latin-1 keys would need this rule widened. - -The charset predicate exists twice, once per source plane. The layout forbids sharing it, and the duplication gate may flag the pair; each side carries its own test and names its twin. - -The costliest way to get this wrong is to treat absence as invalidity. A rule applied to `undefined` would break every route authenticating through ambient discovery or OAuth — `openai-codex` cannot take a key at all — and a blank field that blocked submit would make editing any other setting demand re-entering the key. Both belong in the tests, not only in this note. - -Keys already stored by an earlier build are read through `resolveApiKey`, so an illegal stored value begins failing at resolution rather than at request time. That is the intent — the diagnosis improves — but it moves the failure earlier for anyone currently holding one. diff --git a/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.zh.md b/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.zh.md deleted file mode 100644 index 28073660b1..0000000000 --- a/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.zh.md +++ /dev/null @@ -1,101 +0,0 @@ -# Agent Note: 在 API Key 进入 HTTP header 之前校验其格式 - -Status: proposed - -[English](2026-08-06-api-key-format-validation.md) | 中文 - -## Problem - -一个含有 HTTP header value 无法承载的字符的 API Key,会被每一层配置界面接受,直到构造请求时才失败——离引发它的那个字段已经很远。 - -把含 emoji、中文或全角标点的 Key 粘进 Web 模型设置页,保存会报成功。第一轮对话随即失败于 `Cannot convert argument to a ByteString because the character at index 7 has a value of 55357 which is greater than 255`——其中的下标与码点是 UTF-16 内部细节,不附带任何可执行动作,却泄露了 Key 中某一个字符的码点。`llm-deepseek` 之所以产出这句,是因为 `fetch` 在 [adapter.ts](../../../../packages/llm/llm-deepseek/src/adapter.ts) 的 `try` 内部构造 `Bearer` header,而那个 `catch` 把一切失败都标为 `TRANSPORT`;该标签又在 `DEFAULT_RETRYABLE_CODES` 之中,于是一个永久且确定的故障还会被重试三次。 - -同样的输入在 `llm-pi-ai` 上更糟。它的探测路径在 [discovery.ts](../../../../packages/llm/llm-pi-ai/src/discovery.ts) 里用裸 `fetch` 构造同一个 header,并把一切失败包装成 `could not reach <url>`,于是一个本地的 Key 故障被报成网络不可达。这条探测在保存之前就够得着:`ProviderEditor` 把用户输入的 `keyDraft` 直接放进探测请求,所以「获取模型列表」按钮会在任何东西落盘之前就把非法 Key 发出去。 - -空白字符能通过每一道检查。`ProviderEditor` 判的是 `keyDraft.length`,`resolveAdapterOptions` 判的是 `config.apiKey.length`,于是三个空格构成的 Key 会被存下,随后以 `Bearer` 加若干空格去认证。`llm-pi-ai` 在 `resolveProfiles` 中拒绝空的字面量 `apiKey`,却对来自凭据或环境的 Key 完全不做检查——而那正是模型设置页写入的路径,也就是用户真正走的路径。 - -来源:deepseek-harness#1594 与 #1595;dsh-external#247、#249、#266、#210。 - -## Proposal - -一条规则定义什么是合法 Key:**trim 之后非空,且每个字符都落在 `[\x21-\x7E]`**——可打印 ASCII,不含空格。 - -这一个断言覆盖了来源列出的全部输入:空值、首尾空白、中间空白、C0 控制字符、emoji、中文、全角标点。它同时正是造成 ByteString 失败的那条约束,所以两个 issue 收敛于同一个定义,而不是两个恰好相关的修复。 - -第二条更窄的规则用于识别整行粘贴的环境变量:拒绝匹配 `^[A-Z][A-Z0-9_]*=` 或首尾成对引号的输入。把前缀限定为全大写可以让真实 Key 与之绝缘——`sk-` 这类形态会在连字符处中断标识符匹配。 - -### 不变量属于每一层,启发式属于人所在的那一层 - -字符集规则是不变量。非 ASCII 字符对任何 provider 都**不可能**在 header value 中传输,因此在浏览器、在各个 resolver、在每一次凭据读取上执行它,是结构上的一致而非约定上的一致。 - -形状规则是对人如何粘贴的猜测,因此**只在浏览器中运行**。`llm-pi-ai` 前面挂着 OpenAI、Anthropic 以及任意手工声明的网关,本仓库并不掌握它们的 Key 格式;若这条规则运行在 resolver 中,一个签发形如 `TENANT1=abc` 的网关会让用户被彻底锁死、无路可走——设置页拒绝它,手写的 `.env` 在读取时同样被拒。把启发式限制在粘贴动作发生的那一层,环境变量便始终是那条出路。 - -### 「没有 Key」是一种配置状态,不是缺失 - -在这里,「没有 API Key」意味着三件完全不同的事,其中只有一件是错误。规则作用于**已提供**的值;至于究竟有没有提供,由各个调用方自行判断。 - -**未指定。** 既不写 `apiKey` 也不写 `apiKeyEnv` 的 profile,是由 harness 所持有的 Key 之外的东西来鉴权的。[provider.ts](../../../../packages/llm/llm-pi-ai/src/provider.ts) 中的 `routeAuth` 保留内置 catalog provider 自身的鉴权,正是为了让 provider 原生的 ambient 发现得以存活;而该 catalog 附带的 `openai-codex` 通过 OAuth 鉴权,并会直接拒绝一个显式的 Key。`namesCredential` 的存在就是为了承载这一区分。在 `llm-deepseek` 中,缺省的 `apiKey` 同样会回落到 `apiKeyEnv`。未指定的情形永不参与校验。 - -**Web UI 中留空的输入框。** 即便某个 provider 的 Key 已经存好,该输入框也是空着打开的——`keyStored` 的文案写的是「已配置——输入新值以替换」——所以留空意味着*保持已存储的值*。`ProviderEditor` 在草稿为空时本就完全跳过 `credentials.set`,这一点保持不变:留空绝不能拦截提交,否则改一个 base URL 都得重新输一遍 Key。 - -**已提供,但为空或纯空白。** 这是唯一的错误,因为用户表达了设置 Key 的意图却什么都没给。`llm-pi-ai` 在 `resolveProfiles` 中的措辞本就是对的——*has an empty apiKey; omit it to use ambient authentication*——这种指明合法替代路径而非单纯拒绝的形态,正是其他界面要采用的。 - -因此 `normalizeApiKey` 接受 `string`,而绝非 `string | undefined`。 - -### 规则住在哪里 - -`normalizeApiKey` 是 `dsh-llm` seam 的新模块,与已经承担共享 header 事务的 [attribution.ts](../../../../packages/llm/llm/src/attribution.ts) 并列。两个适配器都依赖该 seam 且都需要这条规则,因此它拥有两个当前消费者而非一个预设消费者。它返回 trim 后的值,或一个原因(`empty`、`illegalCharacters`)。 - -客户端无法引入它:client 包只 reference client 包,因此 `packages/client/ui-models` 镜像这个断言并持有本地化文案,正如今天 `validateDeepSeekModels` 镜像 host 侧的 `catalogModel` schema。两侧在注释中互相指名。 - -### 各个界面各做什么 - -| 界面 | 改动 | -|---|---| -| `dsh-llm` | 新增 `normalizeApiKey`;新增 `INVALID_CREDENTIAL`,刻意不进 `DEFAULT_RETRYABLE_CODES`。 | -| `llm-deepseek` `resolveAdapterOptions` | 归一化已提供的 `apiKey`,与既有的超出 schema 的边界检查并排抛错;使用 trim 后的值。缺省的 `apiKey` 仍照旧回落到 `apiKeyEnv`。关闭 dsh-external#210。 | -| `llm-deepseek` `resolveApiKey` | 归一化凭据 seam 或环境返回的值;以 `INVALID_CREDENTIAL` 拒绝,消息指明模型设置页,绝不回显 Key。 | -| `llm-pi-ai` `resolveProfiles` | 把既有的空值检查扩展为这条共享规则,并保留其「omit it to use ambient authentication」的措辞。 | -| `llm-pi-ai` `resolveApiKey` | 归一化今天完全未受检的凭据与环境路径。不指定任何凭据的 profile 仍原样返回 `undefined`,ambient 与 OAuth 路由不受影响。 | -| `llm-pi-ai` `discoverModels` | 在构造 header 之前归一化,使非法 Key 不再被报成端点不可达。不带 Key 的探测照旧保持未鉴权。 | -| `ui-models` | 镜像字符集规则,加入形状启发式,在探测与 `credentials.set` 之前 trim `keyDraft`,并修正 `stringAt` 的空值判断。留空的输入框仍是可以提交的空操作;只含空白的输入框则以字段级失败呈现,使已输入的内容绝不被静默丢弃。按既有 `modelFailure` 的模式拦截提交并在字段上呈现失败。 | - -`ProviderEditor` 同时服务 DeepSeek 与 pi-ai 两种布局,因此一处客户端改动覆盖两个 provider。 - -`credentials-local` 刻意不动。它存储各类凭据,而可打印 ASCII 是 HTTP header 的约束而非凭据存储的约束;它既有的、拒绝任何 dotenv 样式都无法表示的值的行为保持原样。 - -## Alternatives considered - -**在 `apiKey` schema 字段上加 `.pattern()`。** vendor 中的 schemastery 支持它,且该 pattern 会随命名空间 schema 一同序列化到浏览器——一条规则,投递而非镜像。它落败于 pattern 无法先行 trim:那样 `cordis.yml` 会拒绝带首尾空白的 Key 而 `.env` 却容忍,resolver 与 schema 会对同一个字符串给出分歧。在 `resolveAdapterOptions` 中校验可以让每一层都是 trim-then-validate,而该函数本就是本包重新裁定 schema 无法表达的边界之处。 - -**由 client 与 host 共享一个校验模块。** 被 source plane 布局否决:client 包只 reference client 包外加 `vendor/cordis` 与 `support/invariants`,把它放宽到够得着 host 包会撞上这一分割本就要隔开的两份 `Context` 合并。在两侧各镜像一行断言并各配一份测试,是此处的既定形态。 - -**在适配器的 `catch` 中嗅探 `TypeError`。** 这只是事后归类 ByteString 失败,header 构造本身仍无防护。它依赖 Node 错误消息的措辞,因而会随运行时版本静默失效;它也帮不到 `llm-pi-ai`——后者的 header 构造在 pi-ai SDK 内部。在交出 Key 之前就拒绝,则对两个适配器与探测路径同时有效。 - -**在 `credentials-local.set` 中执行。** 它能一次性拦住所有写入方,包括手工编辑的文件。它落败于该 provider 存储各种类型的凭据,而一条源自 HTTP header 编码的规则并不属于它。 - -**让形状启发式也在 resolver 中运行。** 更对称,且能拦住直接写进 `.env` 的整行环境变量。因上文所述的锁死风险而否决:resolver 中的一次误判会让用户无路可走,浏览器中的一次误判则仍留有环境变量这条路。 - -**在保存时探测 provider 以证明 Key 可用。** 它能关掉来源真正开篇抱怨的那件事——保存报成功、第一轮才失败。因超出范围而否决,且在今天的代码上无法建成:对 pi-ai 恰好自带 catalog 的那些 provider,`discoverModels` 会在任何网络调用之前短路到内置 catalog,因而对 Key 什么都验证不了;而 DeepSeek 卡片根本没有探测。验证器的价值在于分清「Key 被拒」与「无法连通」,而这正是本 Agent Note 要让其变得可靠的区分;先建验证器只会得到一个分不清自身结果的验证器。同类产品也不在保存时验证,因此保存时的阻断式网络调用会是一个意外行为,而非一处缺失。 - -## Acceptance criteria - -- 浏览器、两个 resolver 与两处凭据读取接受与拒绝同一组**已提供**的字符串:纯空白、带首尾空白、含中间空格、C0 控制字符、emoji、中文、全角输入均被拒绝;可打印 ASCII 的 Key 被接受并 trim。 -- 不指定任何凭据的 profile 仍解析为「没有 Key」,通过内置 provider 自身的 ambient 发现或 OAuth 鉴权的路由原样可用。 -- 留空的 Key 输入框可以保存卡片其余部分而不写入凭据;只含空白的输入框则以字段级失败呈现,而不是被静默丢弃。 -- 被拒绝的 Key 在 Web UI 中定位到 API Key 字段并拦截提交;settings 与凭据均不写入。 -- 非法抵达 resolver 的 Key 以 `INVALID_CREDENTIAL` 失败,消息指明修复位置、不含 Key 的任何片段,且不被重试。 -- `llm-pi-ai` 的探测把非法 Key 报为 Key 故障,而非端点不可达。 -- 合法 Key 仍沿既有 `credentials.set` 路径原样通过。 - -## Risks - -形状启发式可能拒绝一个真实的 Key。全大写标识符接 `=`、以及首尾成对引号,都是已知 provider 不会签发的形态,且该规则只在浏览器中运行,因此撞上它的用户仍可通过环境变量设置该凭据。残留代价是对一个尚无人报告过的 Key 给出一次令人困惑的拒绝。 - -限定为可打印 ASCII 比传输本身的要求更严:header value 是可以承载 `\x80`–`\xFF` 的。放行 latin-1 会让 `é` 通过并换回一个语焉不详的 401,而不是一次本地的、有解释的拒绝,因此从严是刻意的。若某个 provider 签发 latin-1 的 Key,这条规则需要放宽。 - -字符集断言存在两份,每个 source plane 一份。布局禁止共享它,重复检测门禁可能会标记这一对;两侧各自带测试并在注释中指名其孪生体。 - -把这件事做错的最大代价,是把「未指定」当成「非法」。一条施加到 `undefined` 上的规则会打断每一条依赖 ambient 发现或 OAuth 鉴权的路由——`openai-codex` 根本无法接受 Key——而一个会拦截提交的空输入框,则会让改动任何其他设置都必须重新输入 Key。这两点都应落在测试里,而不只是写在本 Agent Note 中。 - -早先版本已存下的 Key 会经 `resolveApiKey` 读取,因此一个非法的既存值将从解析时开始失败,而非到请求时才失败。这正是意图所在——诊断变好了——但对当前正持有这类值的人而言,失败点提前了。 diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 1d9117dc85..0468e0e9d1 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -75,6 +75,25 @@ describe('web e2e: Models settings page configures a dormant provider', () => { await compareOrRefreshGolden(EMPTY_EXPECTED, snapshot, MODE) }, 60_000) + it('refuses a key no HTTP header can carry before anything is written', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-models-illegal-key')) + const dialog = page.getByRole('dialog', { name: '设置' }) + const key = dialog.getByLabel('API 密钥') + const save = dialog.getByRole('button', { name: '保存', exact: true }) + + // The paste that used to save cleanly and then fail the first turn with a + // ByteString TypeError now names the field that holds it. + await key.fill('sk-\u{1F600}minimax') + await dialog.getByText('该 API 密钥含有无法发送的字符。请只粘贴原始密钥。').waitFor({ timeout: 10_000 }) + await expect.poll(async () => save.isEnabled(), { timeout: 10_000 }).toBe(false) + + // Clearing it restores submit: an empty field means "keep what is stored", + // never a refusal, or editing any other setting would demand the key. + await key.fill('') + await expect.poll(async () => save.isEnabled(), { timeout: 10_000 }).toBe(true) + expect(await dialog.getByText('该 API 密钥含有无法发送的字符。请只粘贴原始密钥。').count()).toBe(0) + }, 60_000) + it('stores the key under the derived reference and the route registers live', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-models-add')) const dialog = page.getByRole('dialog', { name: '设置' }) From 6b75bb0425bad75fdaa9cb7a1be932ee8276b758 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Thu, 6 Aug 2026 23:15:08 +0800 Subject: [PATCH 44/67] fix(web): say the API key format is wrong rather than naming the characters --- apps/web/tests/models-settings.e2e.ts | 4 ++-- packages/client/ui-models/src/client/locales.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 0468e0e9d1..e5e7d09c5f 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -84,14 +84,14 @@ describe('web e2e: Models settings page configures a dormant provider', () => { // The paste that used to save cleanly and then fail the first turn with a // ByteString TypeError now names the field that holds it. await key.fill('sk-\u{1F600}minimax') - await dialog.getByText('该 API 密钥含有无法发送的字符。请只粘贴原始密钥。').waitFor({ timeout: 10_000 }) + await dialog.getByText('该 API 密钥格式错误,请检查。').waitFor({ timeout: 10_000 }) await expect.poll(async () => save.isEnabled(), { timeout: 10_000 }).toBe(false) // Clearing it restores submit: an empty field means "keep what is stored", // never a refusal, or editing any other setting would demand the key. await key.fill('') await expect.poll(async () => save.isEnabled(), { timeout: 10_000 }).toBe(true) - expect(await dialog.getByText('该 API 密钥含有无法发送的字符。请只粘贴原始密钥。').count()).toBe(0) + expect(await dialog.getByText('该 API 密钥格式错误,请检查。').count()).toBe(0) }, 60_000) it('stores the key under the derived reference and the route registers live', async () => { diff --git a/packages/client/ui-models/src/client/locales.ts b/packages/client/ui-models/src/client/locales.ts index fbfc85c7f1..0d50c03e63 100644 --- a/packages/client/ui-models/src/client/locales.ts +++ b/packages/client/ui-models/src/client/locales.ts @@ -47,7 +47,7 @@ export const en = { removeModel: 'Delete model', modelsEmpty: 'No models will be shown in the selector. Unlisted IDs can still be sent directly.', keyBlank: 'Enter the API key, or leave the field empty to keep the stored one.', - keyIllegalCharacters: 'This API key contains characters that cannot be sent. Paste the raw key only.', + keyIllegalCharacters: 'This API key is not in a valid format. Please check it.', keyLooksWrapped: 'Paste only the key itself — not a NAME=value line, and without surrounding quotes.', modelIdRequired: 'Model ID is required.', modelIdDuplicate: 'Model ID must be unique.', @@ -134,7 +134,7 @@ export const zh: typeof en = { removeModel: '删除模型', modelsEmpty: '模型选择器中将不显示任何模型;目录外 ID 仍可直接发送。', keyBlank: '请输入 API 密钥;留空则保持已存储的密钥。', - keyIllegalCharacters: '该 API 密钥含有无法发送的字符。请只粘贴原始密钥。', + keyIllegalCharacters: '该 API 密钥格式错误,请检查。', keyLooksWrapped: '请只粘贴密钥本身——不要带 NAME=value 整行,也不要带引号。', modelIdRequired: '模型 ID 不能为空。', modelIdDuplicate: '模型 ID 不能重复。', From 1019f149c4e4ad2b75d7c9a15e0976ceaa05e7c7 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Fri, 7 Aug 2026 11:02:38 +0800 Subject: [PATCH 45/67] =?UTF-8?q?fix(web,llm):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20document=20the=20card=20contract,=20pin=20the=20hos?= =?UTF-8?q?t=20diagnosis,=20gate=20the=20probe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/cordis-catalog/services.md | 2 +- .../headless-agent/tests/headless.snapshot.ts | 40 +++++++++++++++++++ .../stream-json.expected.jsonl | 12 ++++++ 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 | 8 +++- .../ui-models/src/client/ModelListEditor.tsx | 13 +++++- .../ui-models/src/client/ProviderEditor.tsx | 2 +- .../client/ui-models/src/client/locales.ts | 2 + .../ui-models/tests/provider-form.spec.tsx | 13 ++++++ .../llm/llm-pi-ai/tests/discovery.spec.ts | 6 ++- packages/llm/llm/src/index.ts | 11 +++-- packages/llm/llm/tests/api-key.spec.ts | 2 +- 14 files changed, 104 insertions(+), 15 deletions(-) create mode 100644 examples/headless-agent/tests/snapshots/invalid-credential/stream-json.expected.jsonl diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 6b30d80751..d71619b7d9 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -941,7 +941,7 @@ stream(options: GenerateOptions): AsyncIterable<StreamChunk> Types: [AdapterRegistrationHandle](../core-data-structures/core.md) · [DirectoryRegistrationHandle](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmConfigurableProvider](../core-data-structures/core.md) · [LlmDiscoveredModel](../core-data-structures/core.md) · [LlmModelDiscoveryRequest](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [LlmResolvedModelInfo](../core-data-structures/core.md) · [PreparedLlmCall](../core-data-structures/llm-streaming.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:287`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:292`](../../packages/llm/llm/src/index.ts) ## `ctx.permission` — `PermissionService` diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index eb39254dac..9de860d2b5 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -31,6 +31,10 @@ const retryScenarioDir = join(snapshotsDir, 'provider-retry') const retryConfigPath = fileURLToPath(new URL('../retry.cordis.snapshot.yml', import.meta.url)) const credentialsScenarioDir = join(snapshotsDir, 'missing-credential') const credentialsConfigPath = fileURLToPath(new URL('../credentials.cordis.snapshot.yml', import.meta.url)) +// Same keyless composition as the missing-credential scenario: the endpoint is +// never dialed either way, because a supplied-but-unusable key fails credential +// resolution exactly where an absent one does. +const invalidCredentialScenarioDir = join(snapshotsDir, 'invalid-credential') const ralphScenarioDir = join(snapshotsDir, 'ralph-loop') const ralphConfigPath = fileURLToPath(new URL('../ralph.cordis.snapshot.yml', import.meta.url)) const startupFailureConfigPath = fileURLToPath(new URL('./fixtures/startup-activation-error/cordis.yml', import.meta.url)) @@ -254,6 +258,42 @@ describe('headless stream-json snapshots', () => { expect(normalized).toContain('as a last resort') }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('logs actionable invalid-credential guidance through the one-shot app', async () => { + const streamExpected = join(invalidCredentialScenarioDir, 'stream-json.expected.jsonl') + let runCwd = '' + const result = await runLoaderSmoke({ + label: 'invalid-credential headless stream-json snapshot', + tempDirPrefix: 'headless-snapshot-invalid-credential-', + binScript, + configPath: credentialsConfigPath, + binArgs: ['--config', credentialsConfigPath, '--output-format', 'stream-json', 'say pong'], + tsconfigPath, + env: { + // A key that exists but no HTTP header can carry — the paste this + // change exists for. Before it, `fetch` refused to build the header + // and the turn ended on a retried ByteString TypeError. + DEEPSEEK_API_KEY: 'sk-\u{1F600}pasted-from-a-chat-window', + DEEPSEEK_BASE_URL: '', + NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), + }, + prepare: (cwd) => { runCwd = cwd }, + }) + + expect(result.stderr).toBe('') + const normalized = normalizeHeadlessStream(result.stdout, runCwd) + if (refreshing) await writeFile(streamExpected, normalized) + expect(normalized).toBe(await readFile(streamExpected, 'utf8')) + // The durable failure names the reference to correct and the writer that + // usually owns it, and stays true in a composition that mounts no Models + // page at all. + expect(normalized).toContain('the API key resolved from DEEPSEEK_API_KEY contains characters') + expect(normalized).toContain('the web Models page writes it') + // Neither the key nor the transport-level symptom it used to produce may + // reach the user: the code point of one character is still the key. + expect(normalized).not.toContain('pasted-from-a-chat-window') + expect(normalized).not.toContain('ByteString') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('logs the model default and a dynamic next-step reasoning effort', async () => { const result = await runLoaderSmoke({ label: 'reasoning effort headless stream-json snapshot', diff --git a/examples/headless-agent/tests/snapshots/invalid-credential/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/invalid-credential/stream-json.expected.jsonl new file mode 100644 index 0000000000..f521487e42 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/invalid-credential/stream-json.expected.jsonl @@ -0,0 +1,12 @@ +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":0,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"say pong"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":1,"time":0,"data":{"turn":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":2,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":4,"time":0,"data":{"content":[{"type":"text","text":"say pong"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":5,"time":0,"data":{"title":"say pong","messageSeqs":[4],"source":{"kind":"fallback"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":6,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":7,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":1000000}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"llm-deepseek: the API key resolved from DEEPSEEK_API_KEY contains characters no HTTP header can carry; set DEEPSEEK_API_KEY to the raw key alone (the web Models page writes it)","code":"INVALID_CREDENTIAL"}}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":9,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":10,"time":0,"data":{"turn":1,"reason":{"kind":"error","error":{"message":"llm-deepseek: the API key resolved from DEEPSEEK_API_KEY contains characters no HTTP header can carry; set DEEPSEEK_API_KEY to the raw key alone (the web Models page writes it)","code":"INVALID_CREDENTIAL"}}}}} +{"type":"result","sessionId":"{{sessionId}}","output":""} diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index ae296a91aa..3db62f6c31 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: e3328bb5fd2cf812b05dc26bf534226818132631 +README.zh.md: 20e40cc571a9123b50dfb28565c5562937e03189 diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index b55914197e..e3328bb5fd 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -8,7 +8,7 @@ Rows are the *configured* providers (their profile resolves in the owning namesp 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. -Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. +Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A typed API key is judged on its own field the same way: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A field holding only whitespace fails rather than being silently dropped, and a value shaped like a pasted `NAME=value` environment line or wrapped in matching quotes fails too; that paste-shape heuristic runs only in the browser, since a false positive in a resolver would leave the environment refusing the key as well. An empty field is not a failure at all: it means keep the stored key on an editor card, and authenticate some other way on a create card. A refused key blocks both the write and the endpoint interrogation, so the page never spends a round trip to be told what the field already says. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. ## Model list and endpoint interrogation diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index ca93c3d5a2..20e40cc571 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -8,7 +8,7 @@ 前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。 -每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 +每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定:trim 之后必须非空,且每个字符都是可打印 ASCII(`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm` 中 `normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。只含空白的输入框会失败,而不是被静默丢弃;形如整行粘贴的 `NAME=value` 环境变量或首尾成对引号包裹的值也会失败——该粘贴形状启发式只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。留空则完全不是失败:在编辑卡片上它意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 ## 模型列表与端点询问 diff --git a/packages/client/ui-models/src/client/CustomProviderCard.tsx b/packages/client/ui-models/src/client/CustomProviderCard.tsx index a252d99586..a610f2f140 100644 --- a/packages/client/ui-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-models/src/client/CustomProviderCard.tsx @@ -215,7 +215,12 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { disabled={disabled} onChange={(event) => { setKeyDraft(event.target.value) }} /> - {keyFailure === undefined ? null : <p className={styles['error']}>{t(keyFailure)}</p>} + {/* A create card has no stored key to keep, so the blank case says + what a blank field means here instead: this route may authenticate + through the provider's own ambient discovery or OAuth. */} + {keyFailure === undefined + ? null + : <p className={styles['error']}>{t(keyFailure === 'keyBlank' ? 'keyBlankNew' : keyFailure)}</p>} </div> <ModelListEditor models={models} @@ -226,6 +231,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { api: protocol, ...keyValue.length === 0 ? {} : { apiKey: keyValue }, }} + probeBlocked={keyFailure === 'keyBlank' ? 'keyBlankNew' : keyFailure} api={api} t={t} disabled={disabled} diff --git a/packages/client/ui-models/src/client/ModelListEditor.tsx b/packages/client/ui-models/src/client/ModelListEditor.tsx index e60c8c24ed..b2966568ae 100644 --- a/packages/client/ui-models/src/client/ModelListEditor.tsx +++ b/packages/client/ui-models/src/client/ModelListEditor.tsx @@ -74,6 +74,13 @@ export interface ModelListEditorProps { onReset?: () => void /** Endpoint facts for the fetch action. */ probe: ProbeTarget + /** + * Copy key naming why the fetch action is unavailable, or `undefined` when + * it is. The card owns this because the key it would send is judged there: + * asking with a key the form has already refused spends a round trip to be + * told what the field already says. + */ + probeBlocked?: keyof typeof en | undefined /** Wire face the fetch action calls. */ api: Pick<IApiClient, 'llm'> /** Section copy. */ @@ -314,8 +321,10 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode { <button type="button" className={styles['linkButton']} - disabled={disabled || busy || !askable} - title={askable ? undefined : t('fetchNeedsBaseUrl')} + disabled={disabled || busy || !askable || props.probeBlocked !== undefined} + title={props.probeBlocked !== undefined + ? t(props.probeBlocked) + : askable ? undefined : t('fetchNeedsBaseUrl')} onClick={() => { void fetchModels() }} > {busy ? t('fetching') : t('fetchModels')} diff --git a/packages/client/ui-models/src/client/ProviderEditor.tsx b/packages/client/ui-models/src/client/ProviderEditor.tsx index f33791eab5..79ac122c83 100644 --- a/packages/client/ui-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-models/src/client/ProviderEditor.tsx @@ -371,7 +371,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { defaultMaxTokens={typeof defaultMaxTokens === 'number' ? defaultMaxTokens : undefined} /> ) - : <ModelListEditor {...catalogProps} probe={probe} api={api} />} + : <ModelListEditor {...catalogProps} probe={probe} probeBlocked={keyFailure} api={api} />} </div> </details> </> diff --git a/packages/client/ui-models/src/client/locales.ts b/packages/client/ui-models/src/client/locales.ts index 0d50c03e63..85f7c14f97 100644 --- a/packages/client/ui-models/src/client/locales.ts +++ b/packages/client/ui-models/src/client/locales.ts @@ -47,6 +47,7 @@ export const en = { removeModel: 'Delete model', modelsEmpty: 'No models will be shown in the selector. Unlisted IDs can still be sent directly.', keyBlank: 'Enter the API key, or leave the field empty to keep the stored one.', + keyBlankNew: 'Enter the API key, or leave the field empty if this provider authenticates another way.', keyIllegalCharacters: 'This API key is not in a valid format. Please check it.', keyLooksWrapped: 'Paste only the key itself — not a NAME=value line, and without surrounding quotes.', modelIdRequired: 'Model ID is required.', @@ -134,6 +135,7 @@ export const zh: typeof en = { removeModel: '删除模型', modelsEmpty: '模型选择器中将不显示任何模型;目录外 ID 仍可直接发送。', keyBlank: '请输入 API 密钥;留空则保持已存储的密钥。', + keyBlankNew: '请输入 API 密钥;若该提供方以其他方式鉴权,可以留空。', keyIllegalCharacters: '该 API 密钥格式错误,请检查。', keyLooksWrapped: '请只粘贴密钥本身——不要带 NAME=value 整行,也不要带引号。', modelIdRequired: '模型 ID 不能为空。', diff --git a/packages/client/ui-models/tests/provider-form.spec.tsx b/packages/client/ui-models/tests/provider-form.spec.tsx index a167710153..13c1511bb2 100644 --- a/packages/client/ui-models/tests/provider-form.spec.tsx +++ b/packages/client/ui-models/tests/provider-form.spec.tsx @@ -954,6 +954,19 @@ describe('API key field', () => { expect((set.mock.calls[0]?.[0] as { value: string }).value).toBe('sk-abc') }) + it('blocks the interrogation too, rather than spending a round trip on a refused key', async () => { + const { discover } = await mountSection() + openEditor('openai') + + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-\u{1F600}' } }) + + // The host would refuse this before building the header anyway; asking is + // a round trip to be told what the field already says. + expect(buttonNamed(en.fetchModels).disabled).toBe(true) + expect(buttonNamed(en.fetchModels).title).toBe(en.keyIllegalCharacters) + expect(discover).not.toHaveBeenCalled() + }) + it('carries the trimmed key into an interrogation, not the padded draft', async () => { const { discover } = await mountSection() openEditor('openai') diff --git a/packages/llm/llm-pi-ai/tests/discovery.spec.ts b/packages/llm/llm-pi-ai/tests/discovery.spec.ts index 63b43ecdab..85221e7ca2 100644 --- a/packages/llm/llm-pi-ai/tests/discovery.spec.ts +++ b/packages/llm/llm-pi-ai/tests/discovery.spec.ts @@ -325,8 +325,10 @@ describe('probe key format', () => { }) it('reports a blank probe key as a credential fault too', async () => { - // A cleared form field arrives as '', not an absent key; it must fail the - // same way a typed-in illegal key does, rather than probing unauthenticated. + // The Models page omits `apiKey` entirely for a cleared field rather than + // sending '', so this pins the contract for every other caller: a supplied + // key is judged, and only an absent one probes unauthenticated. '' means + // "I have a key" and is answered as the empty key it is. await expect(discoverModels({ baseURL: 'https://acme.test', api: 'openai-completions', diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 287bfc2f34..0d1f23af19 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -145,11 +145,16 @@ export class LlmError extends HarnessError { export function assertUsableApiKey(raw: string, pkg: string, ref: string): string { const checked = normalizeApiKey(raw) if (checked.ok) return checked.value + // The Models page is named as the writer it usually is, not as the only one: + // the same value can arrive from a hand-edited .env or a shell export in a + // composition that mounts no credentials seam at all, where directing the + // user to a page that deployment does not serve would be a dead end. throw new LlmError( checked.reason === 'empty' - ? `${pkg}: the API key stored as ${ref} is blank; re-enter it on the web Models page` - : `${pkg}: the API key stored as ${ref} contains characters no HTTP header can carry;` - + ' re-enter it on the web Models page, pasting the raw key only', + ? `${pkg}: the API key resolved from ${ref} is blank; set ${ref} to the raw key` + + ' (the web Models page writes it) or export it in the launching environment' + : `${pkg}: the API key resolved from ${ref} contains characters no HTTP header can carry;` + + ` set ${ref} to the raw key alone (the web Models page writes it)`, INVALID_CREDENTIAL_CODE, ) } diff --git a/packages/llm/llm/tests/api-key.spec.ts b/packages/llm/llm/tests/api-key.spec.ts index a04a103fb9..783b054b9b 100644 --- a/packages/llm/llm/tests/api-key.spec.ts +++ b/packages/llm/llm/tests/api-key.spec.ts @@ -45,7 +45,7 @@ describe('assertUsableApiKey', () => { it('refuses a blank stored credential, naming the reference', () => { expect(() => assertUsableApiKey(' ', 'llm-deepseek', 'DEEPSEEK_API_KEY')) - .toThrow(/llm-deepseek: the API key stored as DEEPSEEK_API_KEY is blank/) + .toThrow(/llm-deepseek: the API key resolved from DEEPSEEK_API_KEY is blank/) }) it('refuses an unusable stored credential with the invalid-credential code', () => { From ec1111f18e4b7c3c87773a485133896078033c3b Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Fri, 7 Aug 2026 11:04:02 +0800 Subject: [PATCH 46/67] docs: keep the API key Agent Note current with the review fixes --- .../bug-fix/2026-08-06-api-key-format-validation.i18n.yaml | 4 ++-- .../bug-fix/2026-08-06-api-key-format-validation.md | 6 ++++-- .../bug-fix/2026-08-06-api-key-format-validation.zh.md | 6 ++++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml index 42b42a591a..2ffa228261 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md -2026-08-06-api-key-format-validation.md: 9ec247cb2ba2578158759ec1115c5d3a95778cc4 -2026-08-06-api-key-format-validation.zh.md: 63c6a8c17ee93b4b68eb3505d5499756e9fb2401 +2026-08-06-api-key-format-validation.md: a0a99bfcace5422ed021d684c5d5aae48c197af7 +2026-08-06-api-key-format-validation.zh.md: b6dc836cbc7bc828f343d9d376dab6e5c3d424ee diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md index 9ec247cb2b..a0a99bfcac 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md @@ -60,7 +60,7 @@ The client cannot import any of this: client packages reference only client pack | `llm-pi-ai` `resolveProfiles` | Applies the shared rule, keeping its "omit it to use ambient authentication" wording, and writes the trimmed value into the resolved profile. | | `llm-pi-ai` `resolveApiKey` | Normalizes the credential and environment paths. A profile naming no credential still returns `undefined`, so ambient and OAuth routes are unaffected. | | `llm-pi-ai` `discoverModels` | Normalizes before building the header, so an illegal key is a credential fault rather than an unreachable endpoint. A probe carrying no key stays unauthenticated. | -| `ui-models` | Mirrors the charset rule, adds the shape heuristic, trims `keyDraft` before probe and `credentials.set`, and fixes the `stringAt` emptiness test. A blank field remains a no-op that submits; a field holding only whitespace is a field-level failure. Submit is gated and the failure renders on the field, matching the existing `modelFailure` pattern. | +| `ui-models` | Mirrors the charset rule, adds the shape heuristic, trims `keyDraft` before probe and `credentials.set`, and fixes the `stringAt` emptiness test. A blank field remains a no-op that submits; a field holding only whitespace is a field-level failure. Submit **and the endpoint interrogation** are both gated, so a refused key never spends a round trip to be told what the field already says, and the failure renders on the field, matching the existing `modelFailure` pattern. | `ProviderEditor` serves both the DeepSeek and pi-ai layouts, so one client change covers both providers. `CustomProviderCard` carries the same judgement for a hand-declared route. @@ -102,4 +102,6 @@ The costliest way to get this wrong would have been to treat absence as invalidi `packages/llm/llm-deepseek/tests/` covers the literal-config path in `adapter.spec.ts` and the stored-credential path end to end in `dynamic-config.spec.ts`, through the real credentials seam rather than a stub. `packages/llm/llm-pi-ai/tests/` covers `resolveProfiles` — including that the trimmed value reaches the resolved profile, which the `...rest` spread would otherwise discard — and the discovery probe, including that a probe with no key sends no `authorization` header. -`packages/client/ui-models/tests/` pins `apiKeyFailure` over the same table plus the paste-shape cases, and drives both cards: a blank field submits without writing a credential, a whitespace-only field fails on the field, an illegal or wrapped key blocks submit, a padded key is trimmed before `credentials.set` and before an interrogation, and a hand-declared route can be created with no key at all. +`packages/client/ui-models/tests/` pins `apiKeyFailure` over the same table plus the paste-shape cases, and drives both cards: a blank field submits without writing a credential, a whitespace-only field fails on the field, an illegal or wrapped key blocks submit and the interrogation alike, a padded key is trimmed before `credentials.set` and before an interrogation, and a hand-declared route can be created with no key at all. + +The user-visible terminal state is pinned where it is actually assembled: `examples/headless-agent/tests/headless.snapshot.ts` runs the one-shot app against a stored key no header can carry, over the same keyless composition its missing-credential sibling uses, and records that the turn ends on `INVALID_CREDENTIAL` with an actionable message carrying neither the key nor the word `ByteString`. A package test could not have shown that, and the web e2e covers only the browser half. diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md index 63c6a8c17e..b6dc836cbc 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md @@ -60,7 +60,7 @@ Status: implemented | `llm-pi-ai` `resolveProfiles` | 施加这条共享规则,保留其「omit it to use ambient authentication」的措辞,并把 trim 后的值写进解析后的 profile。 | | `llm-pi-ai` `resolveApiKey` | 归一化凭据与环境路径。不指定任何凭据的 profile 仍返回 `undefined`,ambient 与 OAuth 路由不受影响。 | | `llm-pi-ai` `discoverModels` | 在构造 header 之前归一化,使非法 Key 成为凭据故障而非端点不可达。不带 Key 的探测保持未鉴权。 | -| `ui-models` | 镜像字符集规则,加入形状启发式,在探测与 `credentials.set` 之前 trim `keyDraft`,并修正 `stringAt` 的空值判断。留空的输入框仍是可以提交的空操作;只含空白的输入框则是字段级失败。提交受拦截,失败呈现在字段上,与既有的 `modelFailure` 模式一致。 | +| `ui-models` | 镜像字符集规则,加入形状启发式,在探测与 `credentials.set` 之前 trim `keyDraft`,并修正 `stringAt` 的空值判断。留空的输入框仍是可以提交的空操作;只含空白的输入框则是字段级失败。提交**与端点探测**同时受拦截,因此被拒绝的密钥不会白花一次往返去换取字段上已经写明的答案;失败呈现在字段上,与既有的 `modelFailure` 模式一致。 | `ProviderEditor` 同时服务 DeepSeek 与 pi-ai 两种布局,因此一处客户端改动覆盖两个 provider。`CustomProviderCard` 为手工声明的路由承载同一套判定。 @@ -102,4 +102,6 @@ Status: implemented `packages/llm/llm-deepseek/tests/` 在 `adapter.spec.ts` 中覆盖字面量配置路径,在 `dynamic-config.spec.ts` 中经真实凭据 seam(而非 stub)端到端覆盖已存储凭据路径。`packages/llm/llm-pi-ai/tests/` 覆盖 `resolveProfiles`——包括 trim 后的值确实到达解析后的 profile,否则会被 `...rest` 展开丢弃——以及探测路径,包括不带 Key 的探测不会发出 `authorization` 标头。 -`packages/client/ui-models/tests/` 以同一张表加上形状用例钉住 `apiKeyFailure`,并驱动两张卡片:留空的输入框可提交且不写入凭据、只含空白的输入框在字段上失败、非法或被包裹的 Key 拦截提交、带首尾空白的 Key 在 `credentials.set` 与探测之前被 trim,以及手工声明的路由可以完全不带 Key 创建。 +`packages/client/ui-models/tests/` 以同一张表加上形状用例钉住 `apiKeyFailure`,并驱动两张卡片:留空的输入框可提交且不写入凭据、只含空白的输入框在字段上失败、非法或被包裹的 Key 同时拦截提交与探测、带首尾空白的 Key 在 `credentials.set` 与探测之前被 trim,以及手工声明的路由可以完全不带 Key 创建。 + +用户可见的终态则钉在它真正被组装的位置:`examples/headless-agent/tests/headless.snapshot.ts` 让 one-shot 应用在一个 HTTP 标头无法承载的已存密钥下运行,复用其 missing-credential 兄弟场景的同一套无密钥 composition,并记录该轮以 `INVALID_CREDENTIAL` 结束、消息可操作且既不含密钥也不含 `ByteString` 字样。包级测试无法证明这一点,而 web e2e 只覆盖了浏览器那一半。 From a7d374426803c73d281f8f3fb84882e3458cdde0 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Fri, 7 Aug 2026 11:16:20 +0800 Subject: [PATCH 47/67] test(web): cover the create card's blank-key copy substitution --- .../ui-models/tests/provider-form.spec.tsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/client/ui-models/tests/provider-form.spec.tsx b/packages/client/ui-models/tests/provider-form.spec.tsx index 13c1511bb2..85125919b5 100644 --- a/packages/client/ui-models/tests/provider-form.spec.tsx +++ b/packages/client/ui-models/tests/provider-form.spec.tsx @@ -880,6 +880,24 @@ describe('hand-declared providers', () => { expect(set).not.toHaveBeenCalled() }) + it('tells a whitespace-only key what a blank field means on a create card', () => { + const { mutate } = mountCard() + + fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme-gateway' } }) + fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://gateway.acme.example/v1' } }) + fireEvent.click(screen.getByRole('button', { name: en.addModel })) + fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } }) + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: ' ' } }) + + // There is no stored key to keep here, so the blank case says the thing + // that is true of a route being declared: it may authenticate elsewhere. + expect(screen.getByText(en.keyBlankNew)).toBeTruthy() + expect(screen.queryByText(en.keyBlank)).toBeNull() + expect(buttonNamed(en.fetchModels).title).toBe(en.keyBlankNew) + expect(buttonNamed(en.create).disabled).toBe(true) + expect(mutate).not.toHaveBeenCalled() + }) + it('creates without a key when the route authenticates some other way', async () => { const { set, onClose } = mountCard() From 5a422337f8fa3c1664b392360593c9fece75abe2 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Fri, 7 Aug 2026 11:35:23 +0800 Subject: [PATCH 48/67] fix(web): silence the stale gate hint, clear whitespace fields, narrow the paste heuristic --- ...-08-06-api-key-format-validation.i18n.yaml | 4 +-- .../2026-08-06-api-key-format-validation.md | 2 +- ...2026-08-06-api-key-format-validation.zh.md | 2 +- .../src/client/CustomProviderCard.tsx | 4 +++ .../ui-models/src/client/ProviderEditor.tsx | 7 ++++- .../client/ui-models/src/client/apiKey.ts | 14 +++++---- .../ui-models/tests/components.spec.tsx | 2 ++ .../ui-models/tests/provider-form.spec.tsx | 31 +++++++++++++++++++ 8 files changed, 55 insertions(+), 11 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml index 2ffa228261..ae2d1d5934 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md -2026-08-06-api-key-format-validation.md: a0a99bfcace5422ed021d684c5d5aae48c197af7 -2026-08-06-api-key-format-validation.zh.md: b6dc836cbc7bc828f343d9d376dab6e5c3d424ee +2026-08-06-api-key-format-validation.md: 4666f6197dbed060d00c77fdd6b87842141c10f4 +2026-08-06-api-key-format-validation.zh.md: 75c98bd29cf009e69ceb450432f540e3f49d99d0 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md index a0a99bfcac..4666f6197d 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md @@ -86,7 +86,7 @@ The client cannot import any of this: client packages reference only client pack A malformed key is refused at the field that holds it, and a malformed stored key fails as `INVALID_CREDENTIAL` with a message naming where to fix it and no fragment of the key. Because that code sits outside `DEFAULT_RETRYABLE_CODES`, a deterministic credential fault is no longer retried three times as a transport blip. `llm-pi-ai` discovery reports an illegal probe key as a credential fault instead of an unreachable endpoint. -The shape heuristic can refuse a real key. Upper-case-identifier-then-`=` and matched surrounding quotes are shapes no known provider issues, and the rule runs only in the browser, so a user who hits it can still set the credential through the environment. The residual cost is a confusing refusal for a key nobody has yet reported. +The shape heuristic can refuse a real key. The first draft matched any upper-case identifier followed by `=`, which review showed was broader than intended: an all-upper-case base64 key ending in padding (`ABCD==`) matched an assignment it does not resemble. Requiring a non-`=` character after the separator excludes padding, since base64 only ever pads at the end. What remains — an upper-case name, one `=`, then a value — is a shape no known provider issues, and the rule runs only in the browser, so a user who still hits it can set the credential through the environment. The residual cost is a confusing refusal for a key nobody has yet reported. Restricting to printable ASCII is stricter than the transport requires: a header value may carry `\x80`–`\xFF`. Admitting latin-1 would let `é` through to return an opaque 401 instead of a local, explained refusal, so the stricter rule is deliberate. A provider that issues latin-1 keys would need this rule widened. diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md index b6dc836cbc..75c98bd29c 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md @@ -86,7 +86,7 @@ Status: implemented 格式错误的 Key 在持有它的那个字段上就被拒绝;格式错误的已存储 Key 以 `INVALID_CREDENTIAL` 失败,消息指明修复位置且不含 Key 的任何片段。由于该 code 位于 `DEFAULT_RETRYABLE_CODES` 之外,一个确定性的凭据故障不再被当作瞬时传输抖动重试三次。`llm-pi-ai` 的探测把非法 Key 报为凭据故障,而非端点不可达。 -形状启发式可能拒绝一个真实的 Key。全大写标识符接 `=`、以及首尾成对引号,都是已知 provider 不会签发的形态,且该规则只在浏览器中运行,因此撞上它的用户仍可通过环境变量设置该凭据。残留代价是对一个尚无人报告过的 Key 给出一次令人困惑的拒绝。 +形状启发式可能拒绝一个真实的 Key。最初的写法匹配任意「全大写标识符接 `=`」,评审指出其覆盖面比预期更宽:一个以 padding 结尾的全大写 base64 Key(`ABCD==`)会命中它并不像的赋值形态。要求分隔符之后必须是非 `=` 字符即可排除 padding——base64 的 padding 只出现在末尾。剩下的形态(大写名称、一个 `=`、然后是值)是已知 provider 不会签发的,且该规则只在浏览器中运行,因此仍撞上它的用户可通过环境变量设置该凭据。残留代价是对一个尚无人报告过的 Key 给出一次令人困惑的拒绝。 限定为可打印 ASCII 比传输本身的要求更严:header value 是可以承载 `\x80`–`\xFF` 的。放行 latin-1 会让 `é` 通过并换回一个语焉不详的 401,而不是一次本地的、有解释的拒绝,因此从严是刻意的。若某个 provider 签发 latin-1 的 Key,这条规则需要放宽。 diff --git a/packages/client/ui-models/src/client/CustomProviderCard.tsx b/packages/client/ui-models/src/client/CustomProviderCard.tsx index a610f2f140..032a056144 100644 --- a/packages/client/ui-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-models/src/client/CustomProviderCard.tsx @@ -93,6 +93,10 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { // because its own field already explains itself, and 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 ? undefined : baseURL.length === 0 ? t('customNeedsBaseUrl') diff --git a/packages/client/ui-models/src/client/ProviderEditor.tsx b/packages/client/ui-models/src/client/ProviderEditor.tsx index 79ac122c83..d72c71cc76 100644 --- a/packages/client/ui-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-models/src/client/ProviderEditor.tsx @@ -167,7 +167,12 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { return typeof value === 'string' && value.trim().length > 0 ? value : undefined } const setField = (key: string, next: string | undefined): void => { - setDraft(current => next === undefined ? deletePath(current, [key]) : setPath(current, [key], next)) + // A value of nothing but whitespace is cleared, not stored: `stringAt` + // already reports it as absent, so the field would otherwise render empty + // while the draft still carried the spaces into `settings.yaml`, where + // both adapters would accept that non-empty string as a real value. + const value = next === undefined || next.trim().length === 0 ? undefined : next + setDraft(current => value === undefined ? deletePath(current, [key]) : setPath(current, [key], value)) } // The model list is validated by the same per-row checker for both families, diff --git a/packages/client/ui-models/src/client/apiKey.ts b/packages/client/ui-models/src/client/apiKey.ts index a9d5bb3d32..5fd1d22ee6 100644 --- a/packages/client/ui-models/src/client/apiKey.ts +++ b/packages/client/ui-models/src/client/apiKey.ts @@ -12,13 +12,15 @@ const LEGAL_API_KEY = /^[\x21-\x7E]+$/ /** - * A pasted `NAME=value` environment line. Restricted to an upper-case - * identifier so a real key cannot match: `sk-` forms break at the hyphen. - * This heuristic runs only here — a resolver applying it could lock a user - * out of a gateway whose key legitimately takes this shape, with the - * environment refusing it too and no way through. + * A pasted `NAME=value` environment line. Two narrowings keep real keys clear + * of it: the name must be upper-case, so `sk-` forms break at the hyphen, and + * the `=` must be followed by something other than another `=`, so base64 + * padding on an all-upper-case key (`ABCD==`) is not mistaken for an + * assignment. This heuristic runs only here — a resolver applying it could + * lock a user out of a gateway whose key legitimately takes this shape, with + * the environment refusing it too and no way through. */ -const ENV_LINE = /^[A-Z][A-Z0-9_]*=/ +const ENV_LINE = /^[A-Z][A-Z0-9_]*=[^=]/ /** Copy key naming why a typed key cannot be saved. */ export type ApiKeyFailureKey = 'keyBlank' | 'keyIllegalCharacters' | 'keyLooksWrapped' diff --git a/packages/client/ui-models/tests/components.spec.tsx b/packages/client/ui-models/tests/components.spec.tsx index d9034ecd44..7228d472cd 100644 --- a/packages/client/ui-models/tests/components.spec.tsx +++ b/packages/client/ui-models/tests/components.spec.tsx @@ -1092,6 +1092,8 @@ describe('apiKeyFailure', () => { ['a padded key, which the caller trims', ' sk-abc '], ['the printable-ASCII boundary characters', '!~'], ['a hyphenated key carrying an equals sign', 'sk-ABC=xyz'], + ['an all-upper-case key ending in base64 padding', 'ABCD=='], + ['an all-upper-case key ending in one padding character', 'MNOPQRST='], ])('accepts %s', (_label, draft) => { expect(apiKeyFailure(draft)).toBeUndefined() }) diff --git a/packages/client/ui-models/tests/provider-form.spec.tsx b/packages/client/ui-models/tests/provider-form.spec.tsx index 85125919b5..5d505386e6 100644 --- a/packages/client/ui-models/tests/provider-form.spec.tsx +++ b/packages/client/ui-models/tests/provider-form.spec.tsx @@ -880,6 +880,22 @@ describe('hand-declared providers', () => { expect(set).not.toHaveBeenCalled() }) + it('stays silent about the other gates when only the key is refused', () => { + mountCard() + + fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme-gateway' } }) + fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://gateway.acme.example/v1' } }) + fireEvent.click(screen.getByRole('button', { name: en.addModel })) + fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } }) + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-\u{1F600}' } }) + + // Route, endpoint, and models are all satisfied, so answering with the + // next unmet gate would print a second, false fault beside the real one. + expect(screen.getByText(en.keyIllegalCharacters)).toBeTruthy() + expect(screen.queryByText(en.customNeedsModels)).toBeNull() + expect(screen.queryByText(en.customNeedsBaseUrl)).toBeNull() + }) + it('tells a whitespace-only key what a blank field means on a create card', () => { const { mutate } = mountCard() @@ -927,6 +943,21 @@ describe('API key field', () => { expect(set).not.toHaveBeenCalled() }) + it('clears a whitespace-only base URL instead of writing the spaces', async () => { + const { mutate } = await mountSection() + openEditor('openai') + + // The field renders this as empty, so the draft must agree: storing the + // spaces would hand both adapters a non-empty string they accept as a URL. + fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: ' ' } }) + fireEvent.click(screen.getByText(en.apply)) + + await waitFor(() => { expect(mutate).toHaveBeenCalled() }) + const ops = firstMutate(mutate).ops + expect(ops.some(op => op.op === 'set' && op.path.includes('baseURL'))).toBe(false) + expect(ops.some(op => op.op === 'unset' && op.path.includes('baseURL'))).toBe(true) + }) + it('blocks submit and names the field when the key holds only whitespace', async () => { const { mutate, set } = await mountSection() openEditor('openai') From 09d1b0d27ff43687970d7b70049dae7843ce8ae4 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Fri, 7 Aug 2026 12:50:23 +0800 Subject: [PATCH 49/67] 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 <jyc132780208@sina.com> Date: Fri, 7 Aug 2026 13:23:19 +0800 Subject: [PATCH 50/67] 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<void> /** 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<Agent, WebLlmTargetRef>() /** 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<DefaultRouteSettings>]: z<string> } { + return { + provider: z.string().required(), + model: z.string().required(), + reasoningEffort: z.string(), + } +} + +/** Schema of the settings section. */ +const DefaultRouteSchema: z<DefaultRouteSettings> = 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<Config> = 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<P>(payload: P): RpcRequest<P> { 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<P>(payload: P): RpcRequest<P> { 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<RpcRequest<MuxFrame>>, 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<Bench> { 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 <jyc132780208@sina.com> Date: Fri, 7 Aug 2026 13:25:46 +0800 Subject: [PATCH 51/67] 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<string | undefined>(undefined) const [models, setModels] = useState<readonly ModelDraft[]>([]) const [busy, setBusy] = useState(false) const [failure, setFailure] = useState<string | undefined>(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) }} /> </div> + {/* The same control the editor card shows for this namespace: a route + declared here and edited there must offer the same profile. */} + <ReasoningEffortField + family="pi-ai" + value={effort ?? ''} + onChange={setEffort} + t={t} + disabled={disabled} + /> <ModelListEditor models={models} onChange={setModels} diff --git a/packages/client/ui-models/src/client/ProviderEditor.tsx b/packages/client/ui-models/src/client/ProviderEditor.tsx index f48572cc58..d25047ff65 100644 --- a/packages/client/ui-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-models/src/client/ProviderEditor.tsx @@ -24,24 +24,14 @@ import { } from './DeepSeekModelsEditor.tsx' 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 = 'deepseek' | 'pi-ai' | 'unknown' - -/** Reasoning vocabularies per layout; the empty option means "inherit". */ -const EFFORT_CHOICES: Record<'deepseek' | 'pi-ai', readonly string[]> = { - 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 { }} /> </div> - <div className={styles['field']}> - <span className={styles['fieldLabel']}>{t('effort')}</span> - <select - className={`${styles['input']} ${styles['selectInput']}`} - value={stringAt(draft, effortField) ?? ''} - aria-label={t('effort')} - disabled={disabled} - onChange={(event) => { - setField(effortField, event.target.value === '' ? undefined : event.target.value) - }} - > - <option value="">{t('effortInherit')}</option> - {EFFORT_CHOICES[family].map(choice => ( - <option key={choice} value={choice}>{choice}</option> - ))} - </select> - </div> + <ReasoningEffortField + family={family} + value={stringAt(draft, effortField) ?? ''} + onChange={(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<EffortFamily, readonly string[]> = { + 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<EffortFamily, string> = { + 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 ( + <div className={styles['field']}> + <span className={styles['fieldLabel']}>{t('effort')}</span> + <select + className={`${styles['input']} ${styles['selectInput']}`} + value={value} + aria-label={t('effort')} + disabled={disabled} + onChange={(event) => { onChange(event.target.value === '' ? undefined : event.target.value) }} + > + <option value="">{t('effortInherit')}</option> + {EFFORT_CHOICES[family].map(choice => ( + <option key={choice} value={choice}>{choice}</option> + ))} + </select> + </div> + ) +} 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 52/67] 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 ( <> - <IconSettingsOutline14 size={wide ? 14 : 18} /> + {wide ? <IconSettingsOutline16 size={16} /> : <IconSettingsOutline14 size={18} />} {wide && <span className={css.triggerLabel}>{t('trigger')}</span>} </> ) 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 0cb922199d6c5341558640bb6f8a19bb1123b2e9 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Fri, 7 Aug 2026 13:26:40 +0800 Subject: [PATCH 53/67] fix(apiproxy): open config files through Windows on WSL --- .../2026-07-30-web-config-plane.i18n.yaml | 4 +- .../2026-07-30-web-config-plane.md | 2 +- .../2026-07-30-web-config-plane.zh.md | 2 +- ...-07-28-tool-call-file-open-in-os.i18n.yaml | 4 +- .../2026-07-28-tool-call-file-open-in-os.md | 5 +- ...2026-07-28-tool-call-file-open-in-os.zh.md | 5 +- .../ui-settings-general/README.i18n.yaml | 4 +- packages/client/ui-settings-general/README.md | 2 +- .../client/ui-settings-general/README.zh.md | 2 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- .../host/apiproxy/src/native-path-opener.ts | 45 ++++++++++-- .../apiproxy/tests/native-path-opener.spec.ts | 71 ++++++++++++++++++- 14 files changed, 128 insertions(+), 26 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml index 8ec7ff129e..b4b1fb5110 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-web-config-plane.md -2026-07-30-web-config-plane.md: 11554077d1848dcdf59b896dd9c29a39fd2f55d4 -2026-07-30-web-config-plane.zh.md: 527c2de8155a56789358b801f9c374e16c81931b +2026-07-30-web-config-plane.md: 0b18cee414df23a2ed8a8b43b76dc06403804691 +2026-07-30-web-config-plane.zh.md: e70c2a47970f943e49393b099c4fcea58dc0fbdc diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md index 11554077d1..0b18cee414 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md @@ -16,7 +16,7 @@ PR1 made LLM adapter configuration restart-free at the seam, but the only writer **`describe()` grows layers and structural secret redaction.** `SettingsDescriptor` carries `base`/`user` beside the effective value, so the form marks "overridden" by presence in the user layer, not value inequality (an override *equal* to the base is still an override). `describe({ redactSecrets: true })` — mandatory at every wire face — strips `role('secret')` subtrees from all three layers via a pure structural walk of the schema (object/dict/array containers; a secret-role subtree is one opaque leaf) and enumerates the stripped slots as `{path, set}`, so a page can render write-only inputs without ever receiving a value. -**The Host identifies and opens the local settings document.** The settings seam exposes optional `documentPath` provider metadata and a `prepareDocument()` operation; `settings-local` returns its fully resolved custom or `$DSH_HOME/settings.yaml` filename and exclusively creates an absent empty document with owner-only permissions, while non-file providers retain the base `undefined`. The loopback-only `settings.describe` response carries only the boolean `hasDocument` capability beside the redacted namespace views. `ui-settings-general` registers a `settings.action` entry only on loopback pages, shows it only after the metadata confirms that a provider-owned local document can be prepared, and invokes pathless `settings.openDocument`; the Host resolves the provider path again before a text-document handoff (`open -t` on macOS so an arbitrary YAML file association cannot redirect the gesture, `xdg-open` on Linux, and `Invoke-Item` on Windows). Generic workspace paths retain the existing default-application handoff. The browser neither derives `$DSH_HOME` nor receives a filesystem target; remote pages make no privileged settings read for this action. +**The Host identifies and opens the local settings document.** The settings seam exposes optional `documentPath` provider metadata and a `prepareDocument()` operation; `settings-local` returns its fully resolved custom or `$DSH_HOME/settings.yaml` filename and exclusively creates an absent empty document with owner-only permissions, while non-file providers retain the base `undefined`. The loopback-only `settings.describe` response carries only the boolean `hasDocument` capability beside the redacted namespace views. `ui-settings-general` registers a `settings.action` entry only on loopback pages, shows it only after the metadata confirms that a provider-owned local document can be prepared, and invokes pathless `settings.openDocument`; the Host resolves the provider path again before a text-document handoff (`open -t` on macOS so an arbitrary YAML file association cannot redirect the gesture, `xdg-open` on desktop Linux, `Invoke-Item` on Windows, and `wslpath -w` followed by that Windows handoff on WSL). Generic workspace paths retain the existing default-application handoff. The browser neither derives `$DSH_HOME` nor receives a filesystem target; remote pages make no privileged settings read for this action. **The llm seam declares configurability and announces topology.** `registerConfigurableProviders()` is an all-or-nothing, fiber-scoped directory of `{provider, displayName, settingsNs, settingsPath}` — the addressing a config page needs to open the right settings subtree for a route that may not exist yet; `listConfigurableProviders()` merges with live routes in the wire handler so undeclared live routes still report active. The zero-payload `'llm/adapters-updated'` event fires from all four registration/unregistration commit points with contained listener dispatch (INVARIANT rethrow), following the settings/commands precedent. `llm-deepseek`'s route renamed to `deepseek-official` because the pi-ai catalog legitimately owns `deepseek` as an aggregator entry; pre-release stance, no alias. diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md index 527c2de815..e70c2a4797 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md @@ -16,7 +16,7 @@ PR1 让 LLM(大语言模型)适配器配置在 seam 层面免重启,但唯 **`describe()` 增加分层与结构化 secret 脱敏。**`SettingsDescriptor` 在生效值之外携带 `base`/`user`,表单据此按「字段是否出现在用户层」来标记「已覆盖」,而非按值是否不等(与 base *相等*的覆盖仍然是覆盖)。`describe({ redactSecrets: true })`——在每个 wire 面都强制启用——经由对 schema 的纯结构遍历(object/dict/array 容器;secret 角色子树整体是一个不透明叶节点)从全部三层剥除 `role('secret')` 子树,并把剥除的槽位枚举为 `{path, set}`,页面因此不必收到任何值就能渲染只写输入框。 -**Host 识别并打开本地设置文档。** settings seam 暴露可选的 `documentPath` 提供方元数据和 `prepareDocument()` 操作;`settings-local` 返回已完全解析的自定义文件名或 `$DSH_HOME/settings.yaml` 文件名,并在文档缺失时以仅属主可访问的权限独占创建空文档,非文件提供方则保留基类的 `undefined`。仅限回环访问的 `settings.describe` 响应会在脱敏 namespace 视图旁只携带布尔型 `hasDocument` 能力。`ui-settings-general` 只在回环页面注册一条 `settings.action` 条目,只有元数据确认可准备好一份由提供方持有的本地文档后才显示,并调用无路径参数的 `settings.openDocument`;Host 会在文本文档交接前再次解析提供方路径(macOS 上使用 `open -t`,使任意 YAML 文件关联无法重定向这次操作;Linux 上使用 `xdg-open`;Windows 上使用 `Invoke-Item`)。通用 Workspace 路径仍保留现有的默认应用交接。浏览器既不推导 `$DSH_HOME`,也不会收到文件系统目标;远程页面不会为这项操作发起特权 settings 读取。 +**Host 识别并打开本地设置文档。** settings seam 暴露可选的 `documentPath` 提供方元数据和 `prepareDocument()` 操作;`settings-local` 返回已完全解析的自定义文件名或 `$DSH_HOME/settings.yaml` 文件名,并在文档缺失时以仅属主可访问的权限独占创建空文档,非文件提供方则保留基类的 `undefined`。仅限回环访问的 `settings.describe` 响应会在脱敏 namespace 视图旁只携带布尔型 `hasDocument` 能力。`ui-settings-general` 只在回环页面注册一条 `settings.action` 条目,只有元数据确认可准备好一份由提供方持有的本地文档后才显示,并调用无路径参数的 `settings.openDocument`;Host 会在文本文档交接前再次解析提供方路径(macOS 上使用 `open -t`,使任意 YAML 文件关联无法重定向这次操作;桌面 Linux 上使用 `xdg-open`;Windows 上使用 `Invoke-Item`;WSL 上先执行 `wslpath -w`,再使用同一 Windows 交接)。通用 Workspace 路径仍保留现有的默认应用交接。浏览器既不推导 `$DSH_HOME`,也不会收到文件系统目标;远程页面不会为这项操作发起特权 settings 读取。 **llm seam 声明可配置性并公布拓扑。**`registerConfigurableProviders()` 是一个全有或全无、以 fiber 为作用域的目录,条目为 `{provider, displayName, settingsNs, settingsPath}`——这正是配置页要为一条可能尚不存在的路由打开正确设置子树时所需要的寻址;`listConfigurableProviders()` 在 wire 处理器里与存活路由合并,未声明的存活路由因此仍报告为激活。零负载的 `'llm/adapters-updated'` 事件从全部四个注册/注销提交点触发,listener 派发带异常隔离(INVARIANT 重抛),沿用 settings/commands 的先例。`llm-deepseek` 的路由重命名为 `deepseek-official`,因为 pi-ai catalog 名正言顺地拥有 `deepseek` 这个聚合器条目;依预发布立场,不设别名。 diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml index 9b8d037c43..a702f1511f 100644 --- a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md -2026-07-28-tool-call-file-open-in-os.md: a2c9b52507d32c2d851f811f0ecdd878a60b1e1c -2026-07-28-tool-call-file-open-in-os.zh.md: 725db61869383711042d85cc1508b00eb1b196b6 +2026-07-28-tool-call-file-open-in-os.md: 73f5091888ab2506eab50b827e74c5120394b127 +2026-07-28-tool-call-file-open-in-os.zh.md: c1bc93c472aae8be8cee3bab6cd02f556abca494 diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md index a2c9b52507..73f5091888 100644 --- a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md @@ -12,12 +12,13 @@ Chat tool rows treated the whole summary line as a click target that opened the File-tool path summaries (`read` / `write` / `edit` args carrying `path` or `file_path`) render as hover-underline links with a pointer cursor. Clicking the path calls `host.openPath` through `WorkspacesService.openPath`, resolving relative paths against the session cwd. File-link rows disable args expand (leading icon is inert); whole-row click, row hover fill, and the click-to-open-details gesture are removed from tool rows (including bash and todo registrations). The details panel and its inject surface remain for programmatic selection; rows no longer drive them. -`host.openPath` is a privileged unary RPC accepted only from loopback, same-origin browser requests (same carrier guard as `host.pickDirectory`). Platform adapters open without a shell: `open` on macOS, PowerShell `Invoke-Item` on Windows, `xdg-open` on Linux. The opener is injectable for tests. URL-only read args (`web_fetch`) are not file links. +`host.openPath` is a privileged unary RPC accepted only from loopback, same-origin browser requests (same carrier guard as `host.pickDirectory`). Platform adapters open without a shell: `open` on macOS, PowerShell `Invoke-Item` on Windows, and `xdg-open` on desktop Linux. WSL is a separate host shape despite Node reporting `linux`: the adapter recognizes its environment or Microsoft kernel release, translates the Linux path with `wslpath -w`, and passes the resulting Windows/UNC path to the same PowerShell handoff. The opener's platform facts and command runner are injectable for tests. URL-only read args (`web_fetch`) are not file links. ## Alternatives considered - Keep row-click details and add a separate file affordance — rejected; the product ask replaces the row gesture with the file link. - Open files inside an in-app preview — rejected; the ask is the OS default application. +- Treat WSL as desktop Linux — rejected; a WSL process reports `linux`, but a Linux desktop association is optional while its ordinary operator desktop and browser live on Windows. - Reuse `host.pickDirectory`'s timeout exemption — unnecessary; path open hand-off completes quickly under the normal unary deadline. ## Consequences @@ -26,5 +27,5 @@ Clicking a file path in a tool row opens that path on the host. Non-file tool ro ## Risks -- Linux hosts without `xdg-open` fail the RPC; the chat row stays silent while the host returns an internal error. +- Desktop Linux hosts without `xdg-open`, and WSL hosts without working Windows interop (`wslpath` plus `powershell.exe`), fail the RPC; the chat row stays silent while the host returns an internal error. - Relative paths without a session cwd are forwarded verbatim and may fail on the host. diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md index 725db61869..c1bc93c472 100644 --- a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md @@ -12,12 +12,13 @@ Status: implemented 文件工具的路径摘要(`read`/`write`/`edit` 参数中的 `path` 或 `file_path`)渲染为悬停下划线链接并使用 pointer 光标。点击路径会经 `WorkspacesService.openPath` 调用 `host.openPath`,相对路径以会话 cwd 为基准解析。带文件链接的行关闭参数展开(左侧图标不可点);工具行(含 bash 与 todo 注册)去掉整行点击、整行悬停底色,以及点击打开 details 的手势。details 面板及其 inject 面仍保留供程序化选择;工具行不再驱动它们。 -`host.openPath` 是特权一元 RPC,仅接受来自回环地址且同源的浏览器请求(与 `host.pickDirectory` 相同的载体守卫)。平台适配器不经 shell 打开:macOS 为 `open`,Windows 为 PowerShell `Invoke-Item`,Linux 为 `xdg-open`。打开器可在测试中注入。仅含 URL 的 read 参数(`web_fetch`)不是文件链接。 +`host.openPath` 是特权一元 RPC,仅接受来自回环地址且同源的浏览器请求(与 `host.pickDirectory` 相同的载体守卫)。平台适配器不经 shell 打开:macOS 为 `open`,Windows 为 PowerShell `Invoke-Item`,桌面 Linux 为 `xdg-open`。尽管 Node 将 WSL 报告为 `linux`,WSL 仍是一种独立的宿主形态:适配器根据其环境或 Microsoft 内核 release 识别它,用 `wslpath -w` 转换 Linux 路径,并将所得 Windows/UNC 路径交给同一 PowerShell 交接。打开器的平台信息和命令运行器可在测试中注入。仅含 URL 的 read 参数(`web_fetch`)不是文件链接。 ## 考虑过的替代方案 - 保留整行点击打开 details,另加文件入口 — 否决;产品要求用文件链接替换整行手势。 - 在应用内预览文件 — 否决;要求是操作系统默认应用。 +- 将 WSL 当作桌面 Linux — 否决;WSL 进程报告 `linux`,但 Linux 桌面文件关联并非必有,而其常规用户桌面和浏览器位于 Windows 上。 - 复用 `host.pickDirectory` 的超时豁免 — 不必要;打开路径的交接在常规一元截止时间内即可完成。 ## 后果 @@ -26,5 +27,5 @@ Status: implemented ## 风险 -- 没有 `xdg-open` 的 Linux 宿主会使 RPC 失败;聊天行保持静默,宿主返回内部错误。 +- 没有 `xdg-open` 的桌面 Linux 宿主,以及 Windows 互操作(`wslpath` 加 `powershell.exe`)不可用的 WSL 宿主,会使 RPC 失败;聊天行保持静默,宿主返回内部错误。 - 没有会话 cwd 时相对路径会原样转发,可能在宿主侧失败。 diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index 9fe338ea47..9c61e62c48 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-general/README.md -README.md: 29e48d193d24644f37d219b4df44a8fedf062e53 -README.zh.md: 17ebc9e8ab273aae0e7ea4c764da569da6d9f49f +README.md: ab27e073dc76335efc619f56365d1705007f7ef2 +README.zh.md: 18bbecf67f51ae63bfacd4ba78437bea95b50bee diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index 29e48d193d..ab27e073dc 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Settings ownerless-copy and product-onboarding plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the local configuration-file action, the General section and its `settings.general.item` slot, the `settings` dictionaries, and the first ordered welcome step. Feature-owned rows (Permission, Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages. -A loopback browser loads the provider's `hasDocument` capability through `settings.describe` and renders **Open configuration file** only when the Host confirms that a provider-owned local document can be prepared. The action sends the pathless, loopback-only `settings.openDocument` request; the Host resolves the provider path again, materializes an absent document, and hands it to a native text editor (`open -t` on macOS, bypassing a browser file association; the desktop file association on Linux and Windows). Open failures keep the action available and render a localized error. Reopening the dialog or reconnecting refreshes availability after a transient read failure or Host topology change. Remote browsers never register the action and never issue the privileged settings read. +A loopback browser loads the provider's `hasDocument` capability through `settings.describe` and renders **Open configuration file** only when the Host confirms that a provider-owned local document can be prepared. The action sends the pathless, loopback-only `settings.openDocument` request; the Host resolves the provider path again, materializes an absent document, and hands it to a native text editor (`open -t` on macOS, bypassing a browser file association; the desktop file association on Linux and Windows; Windows association after `wslpath -w` translation on WSL). Open failures keep the action available and render a localized error. Reopening the dialog or reconnecting refreshes availability after a transient read failure or Host topology change. Remote browsers never register the action and never issue the privileged settings read. `src/onboarding-copy.ts` is the single editable owner of the complete notice plus `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese copy. The Host half registers `ui-onboarding` in the user-settings seam. A loopback browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while `host/settings-changed` makes an externally acknowledged notice advance without a reload. A non-loopback browser cannot access the privileged settings API: it still presents the notice, but Continue advances only the current browser process and a reload presents the notice again. A different version deliberately presents the notice again. The welcome page preserves every authored paragraph, gives the requested clause in the final paragraph the sole emphasis, initially focuses the title, and has no close, Escape, mask-click, or secondary path. None of its copy or acknowledgement enters a Session log or model request. The notice identifies `DSH_TELEMETRY_DISABLED=1` as the telemetry opt-out. diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index 17ebc9e8ab..18bbecf67f 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -4,7 +4,7 @@ 设置界面无特定功能归属的文案与产品引导插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容、本地配置文件操作,「通用」分区及其 `settings.general.item` slot、`settings` 字典,以及第一个有序欢迎步骤。归具体功能所有的行(「权限」、「语言」、「外观」)、分区(「模型」)和条件式首次使用引导步骤仍由各自的功能包提供。 -回环浏览器通过 `settings.describe` 加载提供方的 `hasDocument` 能力,且只有在 Host 确认可准备好一份由提供方持有的本地文档时才渲染**打开配置文件**。该操作发送无路径参数且仅限回环访问的 `settings.openDocument` 请求;Host 会再次解析提供方路径、在文档缺失时将其创建出来,并交给原生文本编辑器(macOS 上使用 `open -t`,绕过浏览器文件关联;Linux 和 Windows 上使用桌面文件关联)。打开失败时该操作仍可使用,并渲染本地化错误。临时读取失败或 Host 拓扑变化后,重新打开对话框或重新连接会刷新可用性。远程浏览器从不注册该操作,也从不发起这项特权 settings 读取。 +回环浏览器通过 `settings.describe` 加载提供方的 `hasDocument` 能力,且只有在 Host 确认可准备好一份由提供方持有的本地文档时才渲染**打开配置文件**。该操作发送无路径参数且仅限回环访问的 `settings.openDocument` 请求;Host 会再次解析提供方路径、在文档缺失时将其创建出来,并交给原生文本编辑器(macOS 上使用 `open -t`,绕过浏览器文件关联;Linux 和 Windows 上使用桌面文件关联;WSL 上经 `wslpath -w` 转换后使用 Windows 文件关联)。打开失败时该操作仍可使用,并渲染本地化错误。临时读取失败或 Host 拓扑变化后,重新打开对话框或重新连接会刷新可用性。远程浏览器从不注册该操作,也从不发起这项特权 settings 读取。 `src/onboarding-copy.ts` 是完整通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源;GUI 支持的两种 locale 都有意渲染同一份中文文案。宿主端在 user-settings seam 中注册 `ui-onboarding`。loopback 浏览器会比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;`host/settings-changed` 则让页面在通知被外部确认后,无需重新加载即可推进。非 loopback 浏览器不能访问受保护的 settings API:它仍会显示通知,但「继续」只推进当前浏览器进程,重新加载后会再次显示通知。版本不同时,系统也会有意重新显示通知。欢迎页保留原文的每个段落,仅强调最后一段中指定的句段,初始焦点落在标题上,并且没有关闭操作、Escape、点击遮罩或次要操作路径。其文案和确认状态均不会进入会话日志或模型请求。通知明确以 `DSH_TELEMETRY_DISABLED=1` 作为遥测关闭方式。 diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 38c79f4617..77fa4afe7d 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: 0963476a767801b465a6ead24feb0ecc9988b5f5 -README.zh.md: e3634c5f92f3a3723eb3c14e39223d9d9550c6f9 +README.md: 395e0d5085878e230fdf7de49a0ca47745bdc270 +README.zh.md: 2ef34f7d6e7ae031dd5f847dfa13827fe4550839 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 0963476a76..395e0d5085 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -32,7 +32,7 @@ A stale continuation discards every partial result, deduplication entry, and cur Directory picking delegates to the composed `ctx.directoryPicker` backend ([the directory-picker seam](../directory-picker/README.md)); a method called outside the composed capability's kind fails with `directory-picker-unavailable` (the client needs no advertisement — the composed picker package's own client half renders the matching interaction). Under `native`, `host.pickDirectory` opens one native chooser and returns its selected path (`null` on cancel); this user-paced method does not use the default 30-second unary timeout, while caller/connection aborts still propagate to the native process. Under `browse`, `host.listDirectory` returns one name-sorted directory level with breadcrumb ancestry, a `home` anchor, and host-owned `hidden` flags (absent path = home directory), and `host.createDirectory` creates one validated child segment; the backend's typed failures map 1:1 onto the `directory-unreadable`/`directory-exists`/`directory-create-failed` codes. The browser carrier's prefix-wide trust fence (dsh-client-connection) covers all of these like every other `/api` request. -`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, `xdg-open` on Linux). The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`. +`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, and `xdg-open` on desktop Linux). WSL translates the Linux path through `wslpath -w` and hands the resulting Windows/UNC path to Windows `Invoke-Item` instead of assuming a Linux desktop association. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`. The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index e3634c5f92..2ef34f7d6e 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -32,7 +32,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr 目录选择委托给组合的 `ctx.directoryPicker` 后端([目录选择 seam](../directory-picker/README.md));调用组合能力 kind 之外的方法会以 `directory-picker-unavailable` 失败(客户端不需要广播——组合的选择器包自己的 client half 渲染匹配的交互)。在 `native` 下,`host.pickDirectory` 打开一个原生选择器并返回选中路径(取消为 `null`);该方法需等待用户完成操作,不使用默认的 30 秒一元调用超时,而调用方与连接的中止仍会传播至原生进程。在 `browse` 下,`host.listDirectory` 返回一个按名称排序的目录层级,携带面包屑祖先链、`home` 锚点与宿主判定的 `hidden` 标志(不带路径即家目录),`host.createDirectory` 创建一个经校验的子段;后端的类型化失败 1:1 映射为 `directory-unreadable`/`directory-exists`/`directory-create-failed` 错误码。浏览器载体的前缀级信任栅栏(dsh-client-connection)像覆盖其他所有 `/api` 请求一样覆盖上述全部方法。 -`host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,Linux 为 `xdg-open`)。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。 +`host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,桌面 Linux 为 `xdg-open`)。WSL 会通过 `wslpath -w` 转换 Linux 路径,并将所得 Windows/UNC 路径交给 Windows `Invoke-Item`,而非假定存在 Linux 桌面文件关联。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。 `command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill;该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 diff --git a/packages/host/apiproxy/src/native-path-opener.ts b/packages/host/apiproxy/src/native-path-opener.ts index a9fdd56bc4..fa7ae5d081 100644 --- a/packages/host/apiproxy/src/native-path-opener.ts +++ b/packages/host/apiproxy/src/native-path-opener.ts @@ -1,5 +1,6 @@ /** Cross-platform native path and text-document openers used by the local GUI carrier. */ +import { release as osRelease } from 'node:os' import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command' /** Testable command boundary; native implementations never invoke a shell. */ @@ -8,6 +9,10 @@ export type PathOpenerRunner = NativeCommandRunner /** Injectable platform facts for deterministic adapter tests. */ export interface PathOpenerInternals { platform?: NodeJS.Platform + /** Kernel release override used to distinguish WSL from desktop Linux. */ + osRelease?: string + /** WSL environment marker override used with the kernel release. */ + env?: Readonly<Partial<Record<'WSL_DISTRO_NAME' | 'WSL_INTEROP', string>>> run?: PathOpenerRunner } @@ -19,6 +24,36 @@ function powershellLiteral(path: string): string { return `'${path.replace(/'/g, "''")}'` } +/** Whether one environment marker is set to a non-empty value. */ +function present(value: string | undefined): boolean { + return value !== undefined && value !== '' +} + +/** Distinguish WSL from desktop Linux using its process and kernel markers. */ +function isWsl(internals: PathOpenerInternals): boolean { + const env = internals.env ?? process.env + if (present(env.WSL_DISTRO_NAME) || present(env.WSL_INTEROP)) return true + return (internals.osRelease ?? osRelease()).toLowerCase().includes('microsoft') +} + +/** Open one Windows-resolvable path through its registered desktop application. */ +async function openWindowsPath(path: string, signal: AbortSignal, run: PathOpenerRunner): Promise<void> { + await run('powershell.exe', [ + '-NoProfile', + '-Command', + `Invoke-Item -LiteralPath ${powershellLiteral(path)}`, + ], signal) +} + +/** Translate a WSL path before handing it to the Windows desktop. */ +async function openWslPath(path: string, signal: AbortSignal, run: PathOpenerRunner): Promise<void> { + const translated = await run('wslpath', ['-w', path], signal) + signal.throwIfAborted() + const windowsPath = translated.stdout.replace(/[\r\n]+$/, '') + if (windowsPath === '') throw new Error('wslpath returned no Windows path') + await openWindowsPath(windowsPath, signal, run) +} + /** Dispatch one shell-free platform command for the requested open intent. */ async function openNativePathWithIntent( path: string, @@ -35,15 +70,15 @@ async function openNativePathWithIntent( } if (platform === 'win32') { - await run('powershell.exe', [ - '-NoProfile', - '-Command', - `Invoke-Item -LiteralPath ${powershellLiteral(path)}`, - ], signal) + await openWindowsPath(path, signal, run) return } if (platform === 'linux') { + if (isWsl(internals)) { + await openWslPath(path, signal, run) + return + } await run('xdg-open', [path], signal) return } diff --git a/packages/host/apiproxy/tests/native-path-opener.spec.ts b/packages/host/apiproxy/tests/native-path-opener.spec.ts index 236de1c9a7..0c6c327273 100644 --- a/packages/host/apiproxy/tests/native-path-opener.spec.ts +++ b/packages/host/apiproxy/tests/native-path-opener.spec.ts @@ -14,6 +14,7 @@ const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn<ExecFileMock>() vi.mock('node:child_process', () => ({ execFile: execFileMock })) +import { release as osRelease } from 'node:os' import { describe, expect, it, vi } from 'vitest' import { openNativePath, openNativeTextFile, type PathOpenerRunner } from '../src/native-path-opener.ts' @@ -34,10 +35,58 @@ describe('native path opener', () => { it('uses the Linux desktop association for text documents', async () => { const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' })) - await openNativeTextFile('/tmp/settings.yaml', signal(), { platform: 'linux', run }) + await openNativeTextFile('/tmp/settings.yaml', signal(), { + platform: 'linux', osRelease: '6.8.0-generic', env: {}, run, + }) expect(run).toHaveBeenCalledWith('xdg-open', ['/tmp/settings.yaml'], expect.any(AbortSignal)) }) + it.each([ + ['distribution marker', { WSL_DISTRO_NAME: 'Ubuntu' }, '6.8.0-generic'], + ['interop marker', { WSL_INTEROP: '/run/WSL/123_interop' }, '6.8.0-generic'], + ['kernel release', {}, '5.15.153.1-microsoft-standard-WSL2'], + ])('hands WSL text documents to the Windows desktop from the %s', async (_label, env, osRelease) => { + const requestSignal = signal() + const run = vi.fn<PathOpenerRunner>(async command => command === 'wslpath' + ? { stdout: '\\\\wsl.localhost\\Ubuntu\\home\\test user\\settings.yaml\r\n', stderr: '' } + : { stdout: '', stderr: '' }) + await openNativeTextFile('/home/test user/settings.yaml', requestSignal, { + platform: 'linux', osRelease, env, run, + }) + expect(run.mock.calls).toEqual([ + ['wslpath', ['-w', '/home/test user/settings.yaml'], requestSignal], + [ + 'powershell.exe', + [ + '-NoProfile', + '-Command', + "Invoke-Item -LiteralPath '\\\\wsl.localhost\\Ubuntu\\home\\test user\\settings.yaml'", + ], + requestSignal, + ], + ]) + }) + + it('rejects an empty WSL path translation before invoking Windows', async () => { + const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '\r\n', stderr: '' })) + await expect(openNativeTextFile('/home/test/settings.yaml', signal(), { + platform: 'linux', osRelease: '6.8.0-generic', env: { WSL_DISTRO_NAME: 'Ubuntu' }, run, + })).rejects.toThrow('wslpath returned no Windows path') + expect(run).toHaveBeenCalledOnce() + }) + + it('does not invoke Windows when the request aborts during WSL path translation', async () => { + const abort = new AbortController() + const run = vi.fn<PathOpenerRunner>(async () => { + abort.abort(new Error('closed')) + return { stdout: '\\\\wsl.localhost\\Ubuntu\\home\\test\\settings.yaml\n', stderr: '' } + }) + await expect(openNativeTextFile('/home/test/settings.yaml', abort.signal, { + platform: 'linux', osRelease: '6.8.0-generic', env: { WSL_DISTRO_NAME: 'Ubuntu' }, run, + })).rejects.toThrow('closed') + expect(run).toHaveBeenCalledOnce() + }) + it('opens with Windows Invoke-Item and escapes single quotes', async () => { const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' })) await openNativePath("C:\\work\\o'reilly.txt", signal(), { platform: 'win32', run }) @@ -60,7 +109,10 @@ describe('native path opener', () => { it('opens with Linux xdg-open', async () => { const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' })) - await openNativePath('/tmp/a.txt', signal(), { platform: 'linux', run }) + await openNativePath('/tmp/a.txt', signal(), { + platform: 'linux', osRelease: '6.8.0-generic', + env: { WSL_DISTRO_NAME: '', WSL_INTEROP: '' }, run, + }) expect(run).toHaveBeenCalledWith('xdg-open', ['/tmp/a.txt'], expect.any(AbortSignal)) }) @@ -71,7 +123,9 @@ describe('native path opener', () => { it('uses the current process platform when no platform override is supplied', async () => { const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' })) - await openNativePath('/tmp/platform-default.txt', signal(), { run }) + await openNativePath('/tmp/platform-default.txt', signal(), { + osRelease: '6.8.0-generic', env: {}, run, + }) const expected = process.platform === 'win32' ? 'powershell.exe' : process.platform === 'linux' @@ -80,6 +134,17 @@ describe('native path opener', () => { expect(run.mock.calls[0]?.[0]).toBe(expected) }) + it('samples ambient WSL markers and kernel release when no fact overrides are supplied', async () => { + const ambientWsl = [process.env.WSL_DISTRO_NAME, process.env.WSL_INTEROP] + .some(value => value !== undefined && value !== '') + || osRelease().toLowerCase().includes('microsoft') + const run = vi.fn<PathOpenerRunner>(async command => command === 'wslpath' + ? { stdout: 'C:\\settings.yaml\n', stderr: '' } + : { stdout: '', stderr: '' }) + await openNativePath('/tmp/ambient-facts.yaml', signal(), { platform: 'linux', run }) + expect(run.mock.calls[0]?.[0]).toBe(ambientWsl ? 'wslpath' : 'xdg-open') + }) + it('runs the default command adapter without a shell and preserves command failures', async () => { execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { callback(null, '', '') From 96795972040151c756323d8bf05504d387aadb8a Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Fri, 7 Aug 2026 13:49:47 +0800 Subject: [PATCH 54/67] 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<typeof watchConsole> + + /** Create one session and its agent through the same wire face the browser uses. */ + const createSession = async (sessionId: string): Promise<string> => { + 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<unknown> => { + 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 `<ROUTE>_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 `<ROUTE>_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.<route>`, and the key travels separately through `credentials.set` under the same `<ROUTE>_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.<route>`, and the key travels separately through `credentials.set` under the same `<ROUTE>_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 没有引用时便派生 `<ROUTE>_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 没有引用时便派生 `<ROUTE>_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.<route>` 上设置整个 profile,密钥则经 `credentials.set` 单独传递,使用与既有提供方相同的 `<ROUTE>_API_KEY` 派生。手工声明的路由无法默认的东西会门控创建按钮——唯一的 **Provider ID**、端点、协议,以及至少一个由唯一标识的模型——因此失败会在用户仍看着该字段时点名它。容量不参与门控:端点只按 id 描述的模型(这正是多数列表返回的形态)由适配器的回退值定尺寸。协议选项读自该 namespace 自己的 schema,而非某个协议字段或常量,因此它们不会与适配器实际接受的集合发生漂移。 +**添加自定义提供方**用来声明 pi-ai 未提供的路由。它是独立的一张卡片而非在编辑器上加字段,因为路由 id 正是在这里被*选定*的,而在选定之前 settings 地址并不存在:一次 `settings.mutate` 在 `providers.<route>` 上设置整个 profile,密钥则经 `credentials.set` 单独传递,使用与既有提供方相同的 `<ROUTE>_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 { <li key={row.entry.provider} className={styles['rowCard']}> <div className={styles['rowHead']}> <span className={styles['rowName']}>{row.entry.displayName}</span> + {/* 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 + ? <span className={styles['rowTag']}>{t('customTag')}</span> + : null} <span className={styles['rowActions']}> <button type="button" diff --git a/packages/client/ui-models/src/client/locales.ts b/packages/client/ui-models/src/client/locales.ts index 19463d98fa..1689c6eab4 100644 --- a/packages/client/ui-models/src/client/locales.ts +++ b/packages/client/ui-models/src/client/locales.ts @@ -65,6 +65,7 @@ export const en = { fetchAdopt: 'Add selected', customAdd: 'Add a custom provider', 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.', @@ -149,6 +150,7 @@ export const zh: typeof en = { fetchAdopt: '添加所选', customAdd: '添加自定义提供方', customTitle: '自定义提供方', + customTag: '自定义', customRoute: 'Provider ID', customRouteHint: '小写标识,在请求中唯一标识该提供方,并用于派生凭据名。', customRouteInvalid: '只能使用小写字母、数字和短横线。', diff --git a/packages/client/ui-models/tests/provider-form.spec.tsx b/packages/client/ui-models/tests/provider-form.spec.tsx index b35302f78a..289cf30867 100644 --- a/packages/client/ui-models/tests/provider-form.spec.tsx +++ b/packages/client/ui-models/tests/provider-form.spec.tsx @@ -66,6 +66,8 @@ function scriptedFace(options: { providers?: Record<string, unknown> /** User layer, when it differs from the effective section. */ userProviders?: Record<string, unknown> + /** Routes the adapter reports as hand-declared; the rest come back as shipped. */ + declaredRoutes?: readonly string[] discover?: ReturnType<typeof vi.fn> mutate?: ReturnType<typeof vi.fn> set?: ReturnType<typeof vi.fn> @@ -86,6 +88,7 @@ function scriptedFace(options: { settingsNs: 'llm-pi-ai', settingsPath: ['providers', provider], active: true, + declared: options.declaredRoutes?.includes(provider) ?? false, })), }))), models: vi.fn(() => Promise.resolve(ok({ groups: [], failures: [] }))), @@ -599,6 +602,53 @@ describe('endpoint interrogation', () => { }) }) +describe('provider rows', () => { + it('tags the routes the adapter declared, and only those', async () => { + await mountSection({ + providers: { + openai: { apiKeyEnv: 'OPENAI_API_KEY' }, + 'acme-gateway': { apiKeyEnv: 'ACME_GATEWAY_API_KEY', baseURL: 'https://acme.test/v1' }, + }, + declaredRoutes: ['acme-gateway'], + }) + + const rowOf = (provider: string): HTMLElement => { + const row = screen.getByText(provider).closest('li') + if (row === null) throw new Error(`no row for ${provider}`) + return row + } + expect(rowOf('acme-gateway').textContent).toContain(en.customTag) + // `openai` carries a stored profile too — the tag follows the adapter's + // catalog, not the presence of settings, so it stays off here. + expect(rowOf('openai').textContent).not.toContain(en.customTag) + }) + + it('shows no tag when the adapter draws no catalog distinction', async () => { + const scripted = scriptedFace({ providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY' } } }) + scripted.face.llm.providers = vi.fn(() => Promise.resolve(ok({ + providers: [{ + provider: 'openai', + displayName: 'openai', + settingsNs: 'llm-pi-ai', + settingsPath: ['providers', 'openai'], + active: true, + }], + }))) as never + const controller = new ModelsSettingsStore(scripted.face as unknown as WireFace) + await controller.load() + render(<ModelsSection + controller={controller} + useSnapshot={bindSnapshotSelector(controller.store)} + api={scripted.face as never} + t={t} + />) + + // Absent is "unknown", never "shipped": an adapter that answers nothing + // must not have its routes labelled either way. + expect(screen.queryByText(en.customTag)).toBeNull() + }) +}) + describe('hand-declared providers', () => { function mountCard(overrides: Partial<Parameters<typeof CustomProviderCard>[0]> = {}) { const scripted = scriptedFace() @@ -683,7 +733,7 @@ describe('hand-declared providers', () => { declare() fireEvent.click(screen.getByText(en.create)) await waitFor(() => { expect(second.onClose).toHaveBeenCalledWith(true) }) - expect(firstMutate(second.mutate).ops[0].value).not.toHaveProperty('reasoning') + expect(firstMutate(second.mutate).ops[0]).not.toHaveProperty('value.reasoning') }) it('names the blocked gate under the form, and nothing once it is satisfied', () => { diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 63c1a491e1..f3ebe32b86 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -2099,7 +2099,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'LlmConfigurableProvider', - declaration: 'export interface LlmConfigurableProvider {\n provider: string;\n displayName: string;\n settingsNs: string;\n settingsPath: readonly string[];\n}', + declaration: 'export interface LlmConfigurableProvider {\n provider: string;\n displayName: string;\n settingsNs: string;\n settingsPath: readonly string[];\n declared?: boolean;\n}', }, { name: 'LlmDiscoveredModel', diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 38c79f4617..a5e9fd2aef 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: 0963476a767801b465a6ead24feb0ecc9988b5f5 -README.zh.md: e3634c5f92f3a3723eb3c14e39223d9d9550c6f9 +README.md: 38f18995f2982db2c5a48971d7d044448e5adc8c +README.zh.md: c444ed6b7485b6ddca059c5edf7f30828bd96ab7 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 0963476a76..38f18995f2 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -2,7 +2,17 @@ English | [中文](README.zh.md) -The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model, workspaceRoot?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped core composition lives in [`packages/bundle/base/cordis.patch.yml`](../../bundle/base/cordis.patch.yml). +The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model, reasoningEffort?, workspaceRoot?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped core composition lives in [`packages/bundle/base/cordis.patch.yml`](../../bundle/base/cordis.patch.yml). + +## The default route (`api-gateway` settings section) + +`{provider, model, reasoningEffort?}` is also the gateway's user-settings section, registered under `api-gateway`: the composition entry is the `base` layer and `settings.yaml` layers the user's own choice over it. `workspaceRoot` is deliberately outside the section — a launcher fact, not a preference. + +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. + +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. ## Contract layer (`/api`) diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index e3634c5f92..c444ed6b74 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -2,7 +2,17 @@ [English](README.md) | 中文 -所有客户端形态共用的 API 网关:TS 契约(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{provider, model, workspaceRoot?}`,提供 `ctx.apiProxy`)。该包在设计上与传输方式无关,不注册任何路由;HTTP 等载体自行包装 `ctx.apiProxy`。已发布的核心组合位于 [`packages/bundle/base/cordis.patch.yml`](../../bundle/base/cordis.patch.yml)。 +所有客户端形态共用的 API 网关:TS 契约(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{provider, model, reasoningEffort?, workspaceRoot?}`,提供 `ctx.apiProxy`)。该包在设计上与传输方式无关,不注册任何路由;HTTP 等载体自行包装 `ctx.apiProxy`。已发布的核心组合位于 [`packages/bundle/base/cordis.patch.yml`](../../bundle/base/cordis.patch.yml)。 + +## 默认路由(`api-gateway` 设置段) + +`{provider, model, reasoningEffort?}` 同时是网关的用户设置段,注册在 `api-gateway` 之下:组合条目是 `base` 层,`settings.yaml` 把用户自己的选择叠加其上。`workspaceRoot` 刻意不在段内——它是启动器事实,不是偏好。 + +会话按三级解析自己的路由,且每次读取都重新解析,而不是只在创建时种一次:本进程内的显式选择,其次是该会话自己最新记录的 `request/header`,最后才是这个默认值。重新解析正是让两个方向都成立的原因——已经跑过一轮的会话此后永远从自己的日志推导路由,改默认值不会重定向它;而仍然空白的会话(新建会话会复用一个,而不是再开一个)则会用上它创建之后才保存的默认值。 + +`session.selectModel` 会把被接受的切换记录为新的默认值,实践中默认值就是这样选定的,没有另一个单独的手势。写入是整段替换而非合并,因为切到一个不支持推理的模型必须清掉已存的等级;存储失败只记日志,不会撤销这次切换——它对自己所在的会话已经生效。没有设置提供方的部署保留组合条目,切换只停留在进程内。 + +存下来的路由不做注册表校验,两个方向都不做。默认值指向一个已在模型页删除的路由时,它照样作为会话的 `current` 送到 `session.models`——匹配不到任何已公布的分组,而这恰恰是让选择器提示重新选择、而不是显示一个部署根本够不着的模型的原因。静默修复它还会破坏刻意保留的反面情形:适配器可以服务一个自己目录未公布的模型。 ## 契约层(`/api`) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 709c63e4d0..aaa2d68a44 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -26,7 +26,8 @@ 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, ConfigurableProviderView, CredentialView, GoalRef, HistoryEntry, HostFrame, + ModelCatalogFailure, ModelProviderGroup, ModelReasoning, MuxFrame, QuestionResponsePayload, SessionProjectionsBlock, SessionSearchItem, QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, ToolEventView, WorkspaceId, WorkspaceView, @@ -2558,15 +2559,17 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const active = new Set(registered.map(provider => provider.id)) const directory = ctx.llm.listConfigurableProviders() const declared = new Set(directory.map(entry => entry.provider)) - const views = directory.map(entry => ({ + const views: ConfigurableProviderView[] = directory.map(entry => ({ provider: entry.provider, displayName: entry.displayName, settingsNs: entry.settingsNs, settingsPath: [...entry.settingsPath], active: active.has(entry.provider), + ...entry.declared === undefined ? {} : { declared: entry.declared }, })) // Routes registered without a directory declaration still appear — - // they exist and serve models — just with no settings address. + // they exist and serve models — just with no settings address. No + // adapter claimed them, so nothing can say whether they are shipped. for (const provider of registered) { if (declared.has(provider.id)) continue views.push({ diff --git a/packages/host/apiproxy/src/api/llm.schema.ts b/packages/host/apiproxy/src/api/llm.schema.ts index 6ded8c32ac..7c8b0e6397 100644 --- a/packages/host/apiproxy/src/api/llm.schema.ts +++ b/packages/host/apiproxy/src/api/llm.schema.ts @@ -16,6 +16,7 @@ export const configurableProviderViewSchema = z.object({ settingsNs: z.string(), settingsPath: z.array(z.string()), active: z.boolean(), + declared: z.boolean().optional(), }) satisfies z.ZodType<Wire<ConfigurableProviderView>> /** llm.providers request payload. */ diff --git a/packages/host/apiproxy/src/api/llm.ts b/packages/host/apiproxy/src/api/llm.ts index edd85a52b2..29cda6cf78 100644 --- a/packages/host/apiproxy/src/api/llm.ts +++ b/packages/host/apiproxy/src/api/llm.ts @@ -22,6 +22,12 @@ export interface ConfigurableProviderView { settingsPath: string[] /** Whether the route is currently registered (its models are requestable). */ active: boolean + /** + * Whether the owning adapter knows this route only because configuration + * declared it. Absent when the adapter draws no such distinction, so a + * surface must treat absence as "unknown", not as "shipped". + */ + declared?: boolean } /** Llm-domain unary methods (the map keys llm.* of RpcMethodMap). */ diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index 34ce49fc77..cb2f88f436 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -66,22 +66,23 @@ export interface Config extends DefaultRouteSettings { 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<DefaultRouteSettings>]: z<string> } { - return { - provider: z.string().required(), - model: z.string().required(), - reasoningEffort: z.string(), - } -} +/** The config fields the settings section carries; the rest stay launcher-owned. */ +const DEFAULT_ROUTE_FIELDS = ['provider', 'model', 'reasoningEffort'] as const -/** Schema of the settings section. */ -const DefaultRouteSchema: z<DefaultRouteSettings> = z.object(defaultRouteFields()) +/** + * 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}. + */ +function defaultRouteSchema(config: z<Config>): z<DefaultRouteSettings> { + const fields = Object.fromEntries( + DEFAULT_ROUTE_FIELDS.map(field => [field, config.dict?.[field]]), + ) + return z.object(fields) as z<DefaultRouteSettings> +} /** Project the stored/composed section onto the agent-facing target shape. */ function routeTarget(settings: DefaultRouteSettings): AgentLlmTarget { @@ -106,7 +107,9 @@ export class ApiProxyService extends Service implements ApiProxy { ] static Config: z<Config> = z.object({ - ...defaultRouteFields(), + provider: z.string().required(), + model: z.string().required(), + reasoningEffort: z.string(), workspaceRoot: z.string(), }) @@ -135,7 +138,7 @@ export class ApiProxyService extends Service implements ApiProxy { ...config.reasoningEffort === undefined ? {} : { reasoningEffort: config.reasoningEffort }, } let route: () => DefaultRouteSettings = () => entry - installSettingsSection(ctx, API_GATEWAY_SETTINGS_NAMESPACE, DefaultRouteSchema, entry, { + installSettingsSection(ctx, API_GATEWAY_SETTINGS_NAMESPACE, defaultRouteSchema(ApiProxyService.Config), entry, { setSource: (current) => { route = current }, diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index b4e9cffabb..641ad62a9c 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: af0e952dd8dbd9767b98229ee6b87262007d6738 -README.zh.md: f8a19999f08aa8a6963874d57bf74370797b951c +README.md: 3eee22198320dbbaf4b53e7d4339f1292f3fa36e +README.zh.md: 3871ab046147f57b442ea8018482080e04567150 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index af0e952dd8..3eee221983 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -49,7 +49,7 @@ Configure credentials, the model catalog, and deployment-specific transport sett maxTokens: 4096 ``` -The dict shape makes duplicate routes unrepresentable, and the pre-release array shape (with per-profile `provider` fields) fails load with migration directions. `providers` may also be empty or omitted entirely: the adapter then mounts **dormant** — zero routes, no extra catalog entries — and registers routes the moment the `llm-pi-ai:` settings section supplies profiles, dropping them again when it empties. Dormant or not, the plugin declares every installed catalog provider in the configurable-provider directory (`ctx.llm.listConfigurableProviders()`, settings path `providers.<provider>`), joined with every route the current profiles declare, so configuration surfaces can offer the full catalog before any route exists and can still address a hand-declared one. Which adapters exist is composition; which providers run can be entirely the user's settings document. Registration with `ctx.llm` is atomic: a collision with any provider route already owned by another adapter fails plugin loading without registering the remaining routes. Model ids are not lifecycle config; a model the route does not configure fails before any provider request with `LlmError('UNKNOWN_MODEL')`. +The dict shape makes duplicate routes unrepresentable, and the pre-release array shape (with per-profile `provider` fields) fails load with migration directions. `providers` may also be empty or omitted entirely: the adapter then mounts **dormant** — zero routes, no extra catalog entries — and registers routes the moment the `llm-pi-ai:` settings section supplies profiles, dropping them again when it empties. Dormant or not, the plugin declares every installed catalog provider in the configurable-provider directory (`ctx.llm.listConfigurableProviders()`, settings path `providers.<provider>`), joined with every route the current profiles declare, so configuration surfaces can offer the full catalog before any route exists and can still address a hand-declared one. Each entry carries `declared`: whether pi-ai ships nothing under that key. It follows the installed catalog, never the settings document, because narrowing a shipped provider's models stores a profile too and that route is still one pi-ai knows — only the adapter can tell the two apart, which is why the directory answers rather than leaving a surface to infer it. Which adapters exist is composition; which providers run can be entirely the user's settings document. Registration with `ctx.llm` is atomic: a collision with any provider route already owned by another adapter fails plugin loading without registering the remaining routes. Model ids are not lifecycle config; a model the route does not configure fails before any provider request with `LlmError('UNKNOWN_MODEL')`. ## Catalog resolution diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index f8a19999f0..3871ab0461 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -49,7 +49,7 @@ maxTokens: 4096 ``` -字典形状使重复路由无法表示,发布前的数组形状(每个 profile 携带 `provider` 字段)会加载失败并给出迁移指引。`providers` 也可以为空或整体省略:适配器将以**休眠**姿态挂载——零路由、模型选择器不多一条——一旦 `llm-pi-ai:` settings 分节提供了 profile 就即时注册路由,分节清空时随之撤销。无论是否休眠,插件都会在可配置提供方目录(`ctx.llm.listConfigurableProviders()`,settings 路径 `providers.<provider>`)中声明每个已安装 catalog 提供方,并与当前 profile 声明的每条路由取并集,因此配置界面既能在任何路由存在之前就提供完整 catalog,也能寻址一条手工声明的路由。哪些适配器存在归组合面;哪些提供方在运行可以完全交给用户的设置文档。向 `ctx.llm` 注册具有原子性:如果与另一适配器已拥有的任何提供方路由冲突,插件会加载失败,不注册剩余路由。模型 id 不是生命周期配置;路由未配置的模型会在发起任何提供方请求前以 `LlmError('UNKNOWN_MODEL')` 失败。 +字典形状使重复路由无法表示,发布前的数组形状(每个 profile 携带 `provider` 字段)会加载失败并给出迁移指引。`providers` 也可以为空或整体省略:适配器将以**休眠**姿态挂载——零路由、模型选择器不多一条——一旦 `llm-pi-ai:` settings 分节提供了 profile 就即时注册路由,分节清空时随之撤销。无论是否休眠,插件都会在可配置提供方目录(`ctx.llm.listConfigurableProviders()`,settings 路径 `providers.<provider>`)中声明每个已安装 catalog 提供方,并与当前 profile 声明的每条路由取并集,因此配置界面既能在任何路由存在之前就提供完整 catalog,也能寻址一条手工声明的路由。每个条目都带上 `declared`:pi-ai 在这个键下是否什么都没有。它跟随已安装 catalog 而非设置文档,因为收窄一个内置提供方的模型同样会存下 profile,而那条路由仍然是 pi-ai 认识的——只有适配器分得清两者,所以由目录直接给出答案,而不是留给界面去猜。哪些适配器存在归组合面;哪些提供方在运行可以完全交给用户的设置文档。向 `ctx.llm` 注册具有原子性:如果与另一适配器已拥有的任何提供方路由冲突,插件会加载失败,不注册剩余路由。模型 id 不是生命周期配置;路由未配置的模型会在发起任何提供方请求前以 `LlmError('UNKNOWN_MODEL')` 失败。 ## Catalog 解析 diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index 0d058e94ac..b2b62dc627 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -92,11 +92,21 @@ function registrationFacts(profiles: ReadonlyMap<string, ResolvedPiAiProviderPro function directoryEntries( profiles: ReadonlyMap<string, ResolvedPiAiProviderProfile>, ): LlmConfigurableProvider[] { + const catalog = new Set(catalogProviderIds()) const entries = new Map<string, LlmConfigurableProvider>() const declare = (provider: string, displayName: string): void => { - entries.set(provider, { provider, displayName, settingsNs: NS, settingsPath: ['providers', provider] }) + entries.set(provider, { + provider, + displayName, + settingsNs: NS, + settingsPath: ['providers', provider], + // Membership of the installed catalog, not of the settings document: + // narrowing a shipped provider's models stores a profile too, and that + // route is still one pi-ai knows. + declared: !catalog.has(provider), + }) } - for (const provider of catalogProviderIds()) declare(provider, provider) + for (const provider of catalog) declare(provider, provider) for (const [provider, profile] of profiles) declare(provider, profile.displayName) return [...entries.values()] } diff --git a/packages/llm/llm-pi-ai/tests/catalog.spec.ts b/packages/llm/llm-pi-ai/tests/catalog.spec.ts index 49805b65b3..2afbb87ec0 100644 --- a/packages/llm/llm-pi-ai/tests/catalog.spec.ts +++ b/packages/llm/llm-pi-ai/tests/catalog.spec.ts @@ -124,13 +124,22 @@ describe('hand-declared providers', () => { it('joins the configurable-provider directory so a settings surface can reach it', async () => { const server = await mockServer([]) const ctx = await harness(gateway(`${server.url}/v1`)) + const directory = ctx.llm.listConfigurableProviders() - expect(ctx.llm.listConfigurableProviders()).toContainEqual({ + expect(directory).toContainEqual({ provider: 'acme-gateway', displayName: 'Acme Gateway', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'acme-gateway'], + // Nothing in the installed catalog answers for this route, which is what + // configuration surfaces mark as a route this deployment declared. + declared: true, }) + // Membership of the catalog, not of the settings document: a shipped + // provider carries a stored profile the moment anyone corrects it. + expect(directory.filter(entry => entry.declared).map(entry => entry.provider)) + .toEqual(['acme-gateway']) + expect(directory.find(entry => entry.provider === 'deepseek')?.declared).toBe(false) }) it('sizes a model the catalog cannot describe from the route\u2019s own fallbacks', () => { diff --git a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts index 3416dc15dc..3a810daa77 100644 --- a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts @@ -68,6 +68,7 @@ describe('request-level dynamic profiles', () => { displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], + declared: false, }) await ctx.settings.update(NS, { providers: { deepseek: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: server.url } }, diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 5e4daa179b..9322ff4b11 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/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/README.md -README.md: ca34ffdeaafdbe061e030c80997b7234ce36a1bd -README.zh.md: 1f95d3cd641126e129f94fe31269454a1bcce972 +README.md: 47fb73cd710a269bc0ac768ef475b56c99b98929 +README.zh.md: b46ecf8ad1904630094b2c2b22ea421a406f4d79 diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index ca34ffdeaa..47fb73cd71 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -13,7 +13,7 @@ An adapter registry plus a single streaming call surface, interceptable via a wa - `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle` Register one adapter instance for the given provider routes. Registration is all-or-nothing, and is disposed with the calling fiber. The returned disposer also carries `replace(providers)`: the candidate route set is validated in full before anything moves, so a conflict with another adapter leaves the current routes registered and serving, and the swap itself is one synchronous section with no observable gap. `replace([])` is legal — a registration holding zero routes — unlike an empty initial registration. - `ctx.llm.listProviders(): LlmProviderInfo[]` Describe registered provider routes in registration order. - `ctx.llm.registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): DirectoryRegistrationHandle` Declare provider routes an adapter plugin can activate through configuration — registered or dormant — each naming its owning settings namespace and the path to its profile inside that section. All-or-nothing (`INVALID_DIRECTORY`/`DUPLICATE_DIRECTORY`), disposed with the calling fiber. The handle also carries `replace(entries)`: the candidate set is validated in full before anything moves, so an entry another registration already declares leaves the current set intact, and an empty array is legal there. A plugin whose declared set follows its configuration must use `replace` rather than disposing and re-registering — the latter strands the directory empty whenever the new set is refused. -- `ctx.llm.listConfigurableProviders(): LlmConfigurableProvider[]` List the declared directory in declaration order; configuration surfaces merge it with `listProviders()` to mark each entry live or dormant. +- `ctx.llm.listConfigurableProviders(): LlmConfigurableProvider[]` List the declared directory in declaration order; configuration surfaces merge it with `listProviders()` to mark each entry live or dormant. An entry may carry `declared` — whether the owning adapter knows that route only because configuration named it. Only the adapter can answer, so absence means "this adapter draws no such distinction", never "shipped". - `ctx.llm.registerModelDiscovery(settingsNs: string, discover): () => void` Offer to interrogate provider endpoints for the settings namespace this plugin owns. One offer per namespace (`INVALID_DISCOVERY`/`DUPLICATE_DISCOVERY`), disposed with the calling fiber. - `ctx.llm.listModelDiscoveryNamespaces(): string[]` List the namespaces that can interrogate an endpoint, so a surface offers the action only where it works. - `ctx.llm.discoverModels(settingsNs: string, request: LlmModelDiscoveryRequest): Promise<LlmDiscoveredModel[]>` Ask one endpoint which models it advertises. diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index 1f95d3cd64..b46ecf8ad1 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -13,7 +13,7 @@ - `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle` 为给定提供方路由注册一个适配器实例。注册要么全部成功,要么全部不生效,并且会随调用 fiber 一起 dispose(资源释放)。返回的释放器还携带 `replace(providers)`:候选路由集合会在任何东西变动之前完整校验,因此与另一适配器冲突时,当前路由保持注册且继续服务,而替换本身是一个同步区段,不存在可观察的空档。`replace([])` 合法——一个持有零条路由的注册——这与空的初始注册不同。 - `ctx.llm.listProviders(): LlmProviderInfo[]` 按注册顺序描述已注册提供方路由。 - `ctx.llm.registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): DirectoryRegistrationHandle` 声明适配器插件可通过配置激活的提供方路由——无论已注册还是休眠——每个条目指明其所属 settings namespace,以及 profile 在该分节内的路径。要么全部成功,要么全部不生效(`INVALID_DIRECTORY`/`DUPLICATE_DIRECTORY`),并随调用 fiber dispose。该句柄还带 `replace(entries)`:候选集合会先被整体校验,因此其中若有条目已被另一个注册声明,当前集合原封不动;此处允许传空数组。声明集合随配置变化的插件必须使用 `replace`,而不是先 dispose 再重新注册——后者会在新集合被拒时让目录整个落空。 -- `ctx.llm.listConfigurableProviders(): LlmConfigurableProvider[]` 按声明顺序列出已声明的目录;配置界面将其与 `listProviders()` 合并,为每个条目标注存活或休眠。 +- `ctx.llm.listConfigurableProviders(): LlmConfigurableProvider[]` 按声明顺序列出已声明的目录;配置界面将其与 `listProviders()` 合并,为每个条目标注存活或休眠。条目可携带 `declared`——拥有该路由的适配器是否只因配置点名才知道它。只有适配器能回答,因此缺席意味着「这个适配器不作此区分」,绝不等于「内置」。 - `ctx.llm.registerModelDiscovery(settingsNs: string, discover): () => void` 为本插件拥有的 settings namespace 提供「询问提供方端点」的能力。每个 namespace 只能有一个(`INVALID_DISCOVERY`/`DUPLICATE_DISCOVERY`),并随调用 fiber dispose。 - `ctx.llm.listModelDiscoveryNamespaces(): string[]` 列出可以询问端点的 namespace,让界面只在可用之处提供该动作。 - `ctx.llm.discoverModels(settingsNs: string, request: LlmModelDiscoveryRequest): Promise<LlmDiscoveredModel[]>` 询问某个端点它公布了哪些模型。 diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index 4980b74405..959190bbbb 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -137,6 +137,15 @@ export 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/tsconfig.host.json b/tsconfig.host.json index 5772905a9e..4823d5fb84 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -9,6 +9,7 @@ }, "include": [ "apps/web/tests/scaffold.ts", + "apps/web/tests/default-model.e2e.ts", "apps/web/tests/support.ts", "apps/web/tests/scaffold-hermetic.e2e.ts", "apps/web/tests/core-web-profile.snapshot.ts", From 768e2e866fa7681107aff90e055e023c44116957 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Fri, 7 Aug 2026 13:56:42 +0800 Subject: [PATCH 55/67] fix(web): keep skill row pairing client-local --- .../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 ------ .../client/contract/terminal-card-model.ts | 11 ++-- .../ui-conversation/tests/chat-view.spec.tsx | 4 +- packages/client/ui-skill/README.i18n.yaml | 4 +- packages/client/ui-skill/README.md | 3 +- packages/client/ui-skill/README.zh.md | 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 | 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 +--- 31 files changed, 79 insertions(+), 273 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 237338a7e6..a9ee64e640 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: bebcf658de33d133ffea8eb190fb4e8e63bf82ff -2026-08-06-web-skill-tool-row.zh.md: 9377829aab1cb6b347cb837dafe7e7e4afb63868 +2026-08-06-web-skill-tool-row.md: 6583062f38b0e9cff059fa4477313ff6a5bdd2aa +2026-08-06-web-skill-tool-row.zh.md: 3d5c4b712896c2cf41df3ec913c597f7f791486c 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 bebcf658de..6583062f38 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. 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. +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. ## 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; the cross-page fix belongs to the generic history pairing envelope used by every tool rather than a skill-specific presentation value. +- Add a new `skill` value to the host tool render-intent union. The keyed client slot already identifies this tool when its call is in the runtime window, 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 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. +Cold replay stays deterministic when the installed skill catalog changes, and the transcript remains compact until instructions are explicitly expanded. A result-only history page intentionally uses the generic fallback; keeping this edge case generic preserves the existing history protocol and confines the feature to client presentation. 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 9377829aab..3d5c4b7128 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 目录来读取描述或提供方元数据。由于 history 页可能包含 `tool/result`,而与之配对的 `tool/call` 已落在窗口外,通用 `HistoryEntry` envelope 现在会在结果条目上携带配对调用的名称、精确的 arguments JSON 和事件时间。Host 从完整日志派生这份瞬时注解和结果渲染意图;runtime 优先使用窗口内调用,否则从该注解物化出相同的 `ToolResultNode.call` 和 `callTime`。无配对结果仍为 `call: null`;调用事件位于页面外时,调用侧渲染意图仍不可用。现有的 ACP(Agent Client Protocol)`skill-load` 记录经由真实的 Web 持久化与组合路径写入,用于无需密钥的交互和无障碍快照。 +该行的所有可见值均派生自当前 runtime 窗口中已配对的调用/结果片段。skill 名称来自已记录的 `name` 参数,指令来自持久化的结果内容;该行绝不关联当前 skill 目录来读取描述或提供方元数据。如果分页将调用留在窗口外,结果便没有工具身份,并继续使用通用后备路径,而不是扩展 history 协议契约。现有的 ACP(Agent Client Protocol)`skill-load` 记录经由真实的 Web 持久化与组合路径写入,用于无需密钥的交互和无障碍快照。 ## 考虑过的替代方案 - 保留通用工具行,只添加一个 `skill` 颜色选择器,并将其放在 `ui-conversation` 中。该方案仍会保留多余的输入外层结构和通用展开体,也会让 conversation 包拥有特定领域的视觉规则。 -- 在宿主工具渲染意图联合类型中添加新的 `skill` 值。键控客户端 slot 已经能够识别该工具;跨页修复属于所有工具共用的通用 history 配对 envelope,而不是 skill 专用的呈现值。 +- 在宿主工具渲染意图联合类型中添加新的 `skill` 值。键控客户端 slot 在调用位于 runtime 窗口内时已经能够识别该工具,因此新的跨边界呈现值只会增加协议和快照表层,却不会支持其他消费方。 - 导出 conversation 包的私有 `ToolRow` 组件供复用。客户端包刻意对外暴露契约而非跨包组件;导出该组件会使独立功能包耦合到 conversation 的实现细节。 ## 后果 除了引用 source 的依赖外,`ui-skill` 现在还依赖公开的 conversation toolview 契约、locale 包、原语包和 React。它自行保留了一小份折叠展开行 chrome,因此未来的全局交互变更必须与 Bash 示例和 conversation 行同步更新这个注册方。 -无论跨越分页,还是已安装的 skill 目录发生变化,冷回放都保持确定性;在用户显式展开指令前,transcript 保持紧凑。通用配对注解还可防止其他键控工具行和结果 presenter 在分页边界改变身份,同时无需持久化重复数据。专用卡片有意显示工具完整封装的输出,而不是只提取 `<skill_instructions>`,从而原样保留模型实际收到的内容,也避免为 skill 结果格式再引入一个解析器。 +即使已安装的 skill 目录发生变化,冷回放仍保持确定性;在用户显式展开指令前,transcript 保持紧凑。仅含结果的 history 页有意使用通用后备路径;让这个边缘情况保持通用呈现,可以保留现有 history 协议,并将该功能限定在客户端呈现层。专用卡片有意显示工具完整封装的输出,而不是只提取 `<skill_instructions>`,从而原样保留模型实际收到的内容,也避免为 skill 结果格式再引入一个解析器。 diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index de15a9c67f..6f29b2dda0 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, HistoryToolCall, ToolEventView, + ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, 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 bacbacdd23..dc2f8c5967 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, HistoryToolCall, HostFrame, MuxFrame, RpcReceipt, + ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt, ModelProviderGroup, ModelTarget, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary, ToolCallView, ToolEventView, ToolResultView, WorkspaceId, WorkspaceView, } from './api.ts' @@ -664,33 +664,25 @@ function presentResult(name: string, argsRaw: string, resultText: string): ToolR } } -/** 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. */ +/** Host-side viewFor mirror: tool/call presents from its own args; tool/result back-scans the log for the paired call. */ 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 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 } + 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 } return undefined } @@ -1055,12 +1047,7 @@ function pageOf( } const events = log.slice(start, end).map((event): HistoryEntry => { const view = viewFor(event, log) - const call = pairedHistoryCall(event, log) - return { - event, - ...view === undefined ? {} : { view }, - ...call === undefined ? {} : { call }, - } + return view === undefined ? { event } : { event, view } }) return { events, hasMore: start > 0 } } diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 83e9722a49..67b47b06c6 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, HistoryToolCall, ToolEventView, + ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, 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 ef94a8834c..23c867e4c0 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: 3d981392ce0314f41fe84bc1adb2b9484a6a5989 -README.zh.md: c05bdb6ebb33c0ffa47e2b54fb1b3d9d25f2fa6d +README.md: 8ac29a4258bbd7456b20c61e547d48c570e84d27 +README.zh.md: 0e065e43ecc571e68d3976d2100eb43959cb2e3d diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 3d981392ce..8ac29a4258 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. 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). +`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). 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 c05bdb6ebb..0e065e43ec 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 顺序。分页得到的 `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` 与本程序的冲突)。 +`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` 与本程序的冲突)。 由于投影按日志顺序,节点数组天然按 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 83a09d3163..d792fd2b76 100644 --- a/packages/client/runtime/src/client/session-history/history-fold.ts +++ b/packages/client/runtime/src/client/session-history/history-fold.ts @@ -362,8 +362,7 @@ export function projectConversationHistory( let contextGeneration = 0 for (const [index, event] of events.entries()) { - const entry = entries[index] - const view = entry?.view + const view = entries[index]?.view if (event.type === 'tool/call') { callIndex.set(String(event.data.callId), { name: event.data.name, @@ -371,17 +370,8 @@ export function projectConversationHistory( time: event.time, callView: view?.for === 'call' ? view.view : null, }) - } 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) + } else if (event.type === 'tool/result' && 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 14bd0dc9ed..d24b963d6b 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 with its durable call head when the Host can resolve it. */ +/** A tool result paired (when in-window) with its call head. */ export interface ToolResultNode { kind: 'tool-result' seq: number /** Unix epoch ms from the tool/result session event. */ time: number callId: string - /** Call head from the window or history envelope; null only when the durable log has no pair (card head shows callId). */ + /** Call head backfilled from the in-window tool/call; null when window truncation left the call outside (card head shows callId). */ call: { name: string; argsRaw: string } | null - /** Unix epoch ms of the paired tool/call; null when the durable log has no pair. */ + /** Unix epoch ms of the paired tool/call when the call is still in-window; used for call-row duration. */ 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 e663af8bf6..776f4494fd 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, HistoryToolCall, IApiClient, MessageId, MuxFrame, QueueAction, RpcError, + HistoryEntry, 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,8 +85,6 @@ 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' @@ -383,11 +381,10 @@ 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, this.historyCalls) // prepend forces a rebuild (the window grew at the head) + this.transcript.reset(this.events, this.views) // prepend forces a rebuild (the window grew at the head) this.rebuildDerivedFromWindow() } catch (error) { console.error('[web-runtime] loadOlder failed:', error) @@ -414,7 +411,6 @@ 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. @@ -648,10 +644,9 @@ 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.historyCalls) + this.transcript.reset(this.events, this.views) this.rebuildDerivedFromWindow() if (projections !== undefined) this.projections.seed(projections) const buffered = this.liveBuffer @@ -666,7 +661,6 @@ 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 b1d952b804..306571b2bf 100644 --- a/packages/client/runtime/src/client/sessions/transcript-adapter.ts +++ b/packages/client/runtime/src/client/sessions/transcript-adapter.ts @@ -19,9 +19,7 @@ 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 { - HistoryToolCall, ToolCallView, ToolEventView, ToolResultView, -} from '@deepseek-ai/dsh-client-connection/client' +import type { 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' @@ -215,13 +213,8 @@ 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)[], - calls?: readonly (HistoryToolCall | undefined)[], - ): void { + reset(events: readonly SessionEvent[], views?: readonly (ToolEventView | undefined)[]): void { this.rev++ this.eventIndex = new Map() this.callIdx = new Map() @@ -235,7 +228,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], calls?.[i]) + this.indexCall(event, views?.[i]) this.indexCommand(event) if (this.steeringHistory.apply(event)) steeringSeqs.add(event.seq) indexAssistantStepTiming(this.stepTimings, event) @@ -345,20 +338,9 @@ export class TranscriptAdapter { return true } - private indexCall(event: SessionEvent, view?: ToolEventView, pairedCall?: HistoryToolCall): void { + private indexCall(event: SessionEvent, view?: ToolEventView): 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 b13e27f3c4..e50574d102 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, HistoryEntry, HostFrame, IApiClient, ModelTarget, MuxFrame, + ClientResponse, CommandDescriptor, 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: HistoryEntry[]; hasMore: boolean }>> = + => Promise<RpcResponse<{ events: never[]; 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 f9b40bdb7c..083bdc3566 100644 --- a/packages/client/runtime/tests/history-fold.spec.ts +++ b/packages/client/runtime/tests/history-fold.spec.ts @@ -53,20 +53,6 @@ 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 02753fe09e..c288c044ee 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -53,23 +53,6 @@ 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 99b4cdf261..031acf1780 100644 --- a/packages/client/runtime/tests/transcript-adapter.spec.ts +++ b/packages/client/runtime/tests/transcript-adapter.spec.ts @@ -365,22 +365,6 @@ 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/contract/terminal-card-model.ts b/packages/client/ui-conversation/src/client/contract/terminal-card-model.ts index b1c4cbe757..8a0c887990 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,12 +168,11 @@ 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 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. + * 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. * @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/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index f0ed99da14..2369e8f1d6 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -285,12 +285,12 @@ describe('chat-flow derivation', () => { }) describe('ChatView', () => { - it('an orphan tool result renders through the generic fallback', () => { + it('a windowless tool result (call head truncated) renders with an empty tool name', () => { const h = makeHarness({ nodes: [{ ...toolResult(3, 'w1'), call: null }], }) const view = render(<h.ChatView {...h.props} />) - // No durable call exists for this id, so the summary falls back to callId. + // classifyTool('') → others; the summary slot falls back to the callId. expect(view.container.querySelector('[data-variant="others"]')).not.toBeNull() expect(view.getByText('w1')).toBeTruthy() }) diff --git a/packages/client/ui-skill/README.i18n.yaml b/packages/client/ui-skill/README.i18n.yaml index 5c50d22b89..57a1ff1676 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: ba9f1faae0f70a0f7bed4641e02703cc26bcb692 -README.zh.md: f8210a885d201cbdc89d7a34704a819e80463d2c +README.md: a9506fe563b94fb4d1f9afd882216e023b0c2d13 +README.zh.md: 6af5d3eb8820dacc2ab569be8b830481dd45fb9a diff --git a/packages/client/ui-skill/README.md b/packages/client/ui-skill/README.md index ba9f1faae0..a9506fe563 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, 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. +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. ## Model Experience @@ -30,6 +30,7 @@ Append-only: the reference is part of a new user message appended after the reus ## Known Limitations and Deferred Work +- **Result-only history pages use the generic row** — keyed dispatch needs the paired call in the runtime window; pagination that leaves the call outside has no tool identity. This client presentation feature does not extend the history wire contract to recover it. - **Non-deterministic skill loading** — the reference is a collaboration cue, not a guarantee; the model may ignore it. The rework path when hit rate proves insufficient (a host-side `context/skill-reference` guidance package, or full-text injection) sits in the design ledger; the wire text shape would not change. - **First keystroke may race the prewarm** — the scope-birth warm launches the catalog fetch, but a menu opened before it settles shows no skill candidates for that keystroke. Accepted by design: skill references do not participate in enter adjudication, so nothing correctness-bearing waits on the catalog. - **Text is the truth** — the reference is plain draft text; a hand-typed identical token is the same reference. Chip visuals derive from the lexicon scan; no occurrence identity or position tracking (componentized chips are a ledger item). diff --git a/packages/client/ui-skill/README.zh.md b/packages/client/ui-skill/README.zh.md index f8210a885d..6af5d3eb88 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` 入口。该行的名称、生命周期和正文只派生自已记录的调用/结果片段;分页将调用事件留在窗口外时,则使用 history envelope 中由 Host 携带的持久配对。该行绝不读取当前 skill 目录,因此冷回放在跨分页时,以及已安装的 skill 或其描述发生变化时均保持稳定。 +浏览器插件还会把一个 key 为 `skill` 的 toolview 注册进 `conversation.chat.toolview`。收起的行以与 Bash 行相同的中性色层级显示 16 像素的 skill 文档与闪光组合图标、`Skill` 标题、分隔符和请求加载的 skill 名称;运行中的调用带有 transcript(文本记录)的扫光效果,失败时用错误首行替换名称,中断调用则使用警告状态。已结算的行以整行作为展开入口,展开后显示一个尺寸受限的 `Instructions` 卡片,其中原样呈现持久化的工具输出;可用时还会提供标准执行轨迹的 `Inspect` 入口。该行的名称、生命周期和正文只派生自当前 runtime 窗口中已配对的调用/结果片段,绝不读取当前 skill 目录,因此即使已安装的 skill 或其描述发生变化,回放仍保持稳定。 ## 模型体验 @@ -30,6 +30,7 @@ skill(技能)引用 source 的浏览器端:把 `/` 触发的 `skill` sourc ## 已知限制与暂缓事项 +- **仅含结果的 history 页使用通用行**:键控分派要求配对调用位于 runtime 窗口内;分页将调用留在窗口外时,结果没有工具身份。这项客户端呈现功能不会为了恢复该身份而扩展 history 协议契约。 - **skill 加载具有非确定性**:引用是协作线索,不是保证;模型可能忽略它。针对命中率不足情况的返工路径(host 侧 `context/skill-reference` 引导包,或全文注入)记录在设计台账中;协议中的文本形态不会改变。 - **首次击键可能与预热竞速**:scope 创建时的预热会启动目录拉取,但目录落定之前打开的菜单,在那次击键下不会显示 skill 候选。这是设计上接受的取舍:skill 引用不参与回车裁决,因此没有任何攸关正确性的环节等待目录。 - **文本是唯一依据**:引用是普通的草稿文本;手动键入的相同 token 就是同一个引用。chip 视觉由 lexicon 扫描派生;没有 occurrence 身份或位置跟踪(组件化 chip 是台账事项)。 diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index f587ca109e..38c79f4617 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: bd8ad485980348a23054d5446d292ef0c24536dd -README.zh.md: 7906ca224930736f335dcbdedc63b6af3019c070 +README.md: 0963476a767801b465a6ead24feb0ecc9988b5f5 +README.zh.md: e3634c5f92f3a3723eb3c14e39223d9d9550c6f9 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index bd8ad48598..0963476a76 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. 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` 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`'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 7906ca2249..e3634c5f92 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)的仅日志溯源信息与引用它的替换留在同一页。`tool/result` 条目还会携带其配对调用的名称、精确的 arguments JSON 和事件时间,作为从完整日志派生的瞬时 history 注解,因此分页切分无法抹掉键控 toolview 分派、由参数派生的摘要或耗时。结果渲染意图使用完整日志中的同一配对;无配对结果或参数损坏时,仍会软降级到通用呈现路径。 +`session.history` 按追加来源的消息边界分页:`maxMessages` 统计以追加方式进入 surface 的 `user/message` 和 `assistant/message` 事件,因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间,从而让压缩(compaction)的仅日志溯源信息与引用它的替换留在同一页。 `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 998e8d3ac5..19fb0fe8a2 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, HistoryToolCall, HostFrame, ModelCatalogFailure, ModelProviderGroup, + ApiProxy, CredentialView, GoalRef, HistoryEntry, 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: 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: + * (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: * the client's documented default (generic JSON card) covers every miss. */ function viewFor(ctx: Context, event: SessionEvent, argsFor: (callId: string) => unknown): ToolEventView | undefined { @@ -442,8 +442,10 @@ function viewFor(ctx: Context, event: SessionEvent, argsFor: (callId: string) => } /** - * Resolve a tool/result's call pairing by scanning a live session backwards - * for the matching tool/call after the open-call table missed. + * 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. */ function backscanArgs(events: readonly SessionEvent[], callId: string): { name: string; args: unknown } | undefined { for (let i = events.length - 1; i >= 0; i--) { @@ -461,34 +463,6 @@ 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, @@ -497,18 +471,10 @@ 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 => 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 }, - } + const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId)) + return { event, ...view === undefined ? {} : { view } } }), hasMore: page.hasMore, } diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index 697e5bdeae..4f10d92853 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, HistoryToolCall, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, + HistoryEntry, 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 f47289e77b..9f9c4329e6 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, HistoryToolCall, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, + HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, ModelReasoningEffort, ModelTarget, SessionProjectionsBlock, SessionSearchItem, SessionSummary, } from './sessions.ts' import type { ToolEventView } from './events.ts' @@ -193,18 +193,10 @@ export const toolEventViewSchema = z.discriminatedUnion('for', [ z.object({ for: z.literal('result'), view: z.looseObject({ card: z.string() }) }), ]) as unknown as z.ZodType<ToolEventView> -/** 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. */ +/** One session.history item: the session event plus its optional host-computed tool view. */ 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 2a6da96db9..18315eef19 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -26,26 +26,14 @@ 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 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. + * 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). */ 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 3b19a26b5e..43083545db 100644 --- a/packages/host/apiproxy/tests/api-proxy-view.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-view.spec.ts @@ -231,50 +231,9 @@ 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 3a76dd9b07..b65861c1ae 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -190,16 +190,10 @@ 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: [{ - event: { type: 'tool/result', seq: 3, time: 30, data: {} }, - call: { name: 'skill', arguments: '{"name":"review"}', time: 20 }, - }], + events: [], hasMore: false, modelTarget: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })).toMatchObject({ - events: [{ call: { name: 'skill', arguments: '{"name":"review"}', time: 20 } }], - hasMore: false, - }) + }).hasMore).toBe(false) expect(sessionModelsRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1') expect(sessionModelsValueSchema.parse({ current: { provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: 'max' }, From 72618f29b5573de77c6ba1f79c4f8b079c098523 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Fri, 7 Aug 2026 13:57:52 +0800 Subject: [PATCH 56/67] fix(ui-models): stop the create card pinning a reference on a blank key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Master's credential-lifecycle work taught the editor card that a pi-ai profile names `apiKeyEnv` only when a key is actually stored, so a route left blank keeps its provider-native auth path. The create card kept writing the derived reference unconditionally, so a route declared for a credential chain or ADC was born pointing at a reference nothing sets — and now rendered a red missing-key dot for it. Both cards apply one rule. The obsolete assertion moves with the behavior (the with-key case is covered by the neighbouring test), and the merged Models e2e golden shows the declared route unmarked rather than flagged. --- .../tests/snapshots/models-settings/declared.expected.md | 9 +++++---- packages/client/ui-models/README.i18n.yaml | 4 ++-- packages/client/ui-models/README.md | 2 +- packages/client/ui-models/README.zh.md | 2 +- .../client/ui-models/src/client/CustomProviderCard.tsx | 9 +++++++-- packages/client/ui-models/tests/provider-form.spec.tsx | 4 +++- 6 files changed, 19 insertions(+), 11 deletions(-) diff --git a/apps/web/tests/snapshots/models-settings/declared.expected.md b/apps/web/tests/snapshots/models-settings/declared.expected.md index 3aa3d64cc9..df47e186c3 100644 --- a/apps/web/tests/snapshots/models-settings/declared.expected.md +++ b/apps/web/tests/snapshots/models-settings/declared.expected.md @@ -16,12 +16,13 @@ - list: - listitem: - text: minimax-cn - - button "编辑" - - button "删除" + - img "API 密钥已配置" + - button "编辑 minimax-cn": 编辑 + - button "删除 minimax-cn": 删除 - listitem: - text: Acme Gateway 自定义 - - button "编辑" - - button "删除" + - button "编辑 Acme Gateway (acme-gateway)": 编辑 + - button "删除 Acme Gateway (acme-gateway)": 删除 - button "添加提供方": - img - text: 添加提供方 diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index 6f7c8deba3..01bf1be683 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: 9d1fbdddd1ad9ec4c073dd1c0ca4ac7124c1b876 -README.zh.md: ff740bc6d1096901cbcc33772aff09deafeb53a4 +README.md: fdd27478be25d4352462c2db0fc7eead1d1aee77 +README.zh.md: c0fd66b003593e7a535e28751f1f6d0ee80dfac1 diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index 35e71c0dc0..fdd27478be 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.<route>`, and the key travels separately through `credentials.set` under the same `<ROUTE>_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. +**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.<route>`, and the key travels separately through `credentials.set` under the same `<ROUTE>_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. 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 bc90540815..c0fd66b003 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.<route>` 上设置整个 profile,密钥则经 `credentials.set` 单独传递,使用与既有提供方相同的 `<ROUTE>_API_KEY` 派生。手工声明的路由无法默认的东西会门控创建按钮——唯一的 **Provider ID**、端点、协议,以及至少一个由唯一标识的模型——因此失败会在用户仍看着该字段时点名它。容量不参与门控:端点只按 id 描述的模型(这正是多数列表返回的形态)由适配器的回退值定尺寸。协议选项读自该 namespace 自己的 schema,而非某个协议字段或常量,因此它们不会与适配器实际接受的集合发生漂移。这张卡片提供与编辑器卡片在该 namespace 下相同的提供方级推理等级,两者共用同一个控件:它们写的是同一个 profile 字段,若声明时没有它,这个设置就会等到重新打开编辑时才凭空出现。 +**添加自定义提供方**用来声明 pi-ai 未提供的路由。它是独立的一张卡片而非在编辑器上加字段,因为路由 id 正是在这里被*选定*的,而在选定之前 settings 地址并不存在:一次 `settings.mutate` 在 `providers.<route>` 上设置整个 profile,密钥则经 `credentials.set` 单独传递,使用与既有提供方相同的 `<ROUTE>_API_KEY` 派生。手工声明的路由无法默认的东西会门控创建按钮——唯一的 **Provider ID**、端点、协议,以及至少一个由唯一标识的模型——因此失败会在用户仍看着该字段时点名它。容量不参与门控:端点只按 id 描述的模型(这正是多数列表返回的形态)由适配器的回退值定尺寸。协议选项读自该 namespace 自己的 schema,而非某个协议字段或常量,因此它们不会与适配器实际接受的集合发生漂移。只有键入了密钥,这张卡片才记录约定的 `apiKeyEnv` 引用,与编辑器同一条规则,因此一条为提供方原生认证声明的路由不会一出生就指向一个永远不会被设置的引用。这张卡片提供与编辑器卡片在该 namespace 下相同的提供方级推理等级,两者共用同一个控件:它们写的是同一个 profile 字段,若声明时没有它,这个设置就会等到重新打开编辑时才凭空出现。 ## 模型体验 diff --git a/packages/client/ui-models/src/client/CustomProviderCard.tsx b/packages/client/ui-models/src/client/CustomProviderCard.tsx index 4bd14d1179..ffdc57baf0 100644 --- a/packages/client/ui-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-models/src/client/CustomProviderCard.tsx @@ -98,9 +98,14 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { /** Perform the create, returning a failure message or undefined. */ const createOnce = async (): Promise<string | undefined> => { const keyRef = deriveKeyRef(route) + const storesKey = keyDraft.trim().length > 0 const profile = { ...displayName.length === 0 ? {} : { displayName }, - apiKeyEnv: keyRef, + // The profile names the conventional reference only when this card is + // about to store a key, matching the editor: a route declared with the + // key left blank keeps its provider-native auth path (a credential + // chain, ADC) instead of resolving a reference nothing ever sets. + ...storesKey ? { apiKeyEnv: keyRef } : {}, api: protocol, baseURL, // Inherit is the field being absent, not an empty string: the schema @@ -117,7 +122,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { expectedRevision: openedAt, }) if (!response.result.ok) return response.result.error.message - if (keyDraft.length > 0) { + if (storesKey) { const stored = await api.credentials.set({ ref: keyRef, value: keyDraft }) // The profile landed; saying the key did not is the only honest report, // and the row is now editable so the key can be entered again there. diff --git a/packages/client/ui-models/tests/provider-form.spec.tsx b/packages/client/ui-models/tests/provider-form.spec.tsx index 0a207d0a01..67def367bc 100644 --- a/packages/client/ui-models/tests/provider-form.spec.tsx +++ b/packages/client/ui-models/tests/provider-form.spec.tsx @@ -899,8 +899,10 @@ describe('hand-declared providers', () => { await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) }) // No display name configured means none stored; the route id is the name. + // No key typed means no reference either, matching the editor: the route + // keeps its provider-native auth path instead of resolving a reference + // nothing ever sets. The with-key case is covered above. expect(firstMutate(mutate).ops[0]?.value).toEqual({ - apiKeyEnv: 'ACME_API_KEY', api: 'anthropic-messages', baseURL: 'https://acme.test/v1', models: [{ id: 'm' }], From 0823a3484a6279171e9552696ea39be55ec250c7 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Fri, 7 Aug 2026 14:03:11 +0800 Subject: [PATCH 57/67] fix(web): report a wrapped paste as the same API key format failure --- .../2026-08-06-api-key-format-validation.i18n.yaml | 4 ++-- .../bug-fix/2026-08-06-api-key-format-validation.md | 2 +- .../2026-08-06-api-key-format-validation.zh.md | 2 +- packages/client/ui-models/README.i18n.yaml | 4 ++-- packages/client/ui-models/README.md | 2 +- packages/client/ui-models/README.zh.md | 2 +- packages/client/ui-models/src/client/apiKey.ts | 12 +++++++++--- packages/client/ui-models/src/client/locales.ts | 2 -- packages/client/ui-models/tests/components.spec.tsx | 4 ++-- .../client/ui-models/tests/provider-form.spec.tsx | 2 +- 10 files changed, 20 insertions(+), 16 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml index ae2d1d5934..d5418088d7 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md -2026-08-06-api-key-format-validation.md: 4666f6197dbed060d00c77fdd6b87842141c10f4 -2026-08-06-api-key-format-validation.zh.md: 75c98bd29cf009e69ceb450432f540e3f49d99d0 +2026-08-06-api-key-format-validation.md: c85d03119565a25abb37a0d32550d46796148134 +2026-08-06-api-key-format-validation.zh.md: 84e5458675995d2454fa4becc5e6466f511c4932 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md index 4666f6197d..c85d031195 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md @@ -22,7 +22,7 @@ One rule defines a legal key: **after trimming, non-empty, and every character w This single predicate covers every input the sources list: empty, leading and trailing whitespace, interior whitespace, C0 control characters, emoji, CJK text, and full-width punctuation. It is also exactly the constraint that produced the ByteString failure, so the two issues close on one definition rather than on two coincidentally related fixes. -A second, narrower rule catches a pasted environment line: input matching `^[A-Z][A-Z0-9_]*=` or wrapped in matching quotes is refused. Restricting the prefix to upper-case keeps real keys clear of it — `sk-` forms break the identifier match at the hyphen. +A second, narrower rule catches a pasted environment line: input matching `^[A-Z][A-Z0-9_]*=[^=]` or wrapped in matching quotes is refused. Restricting the prefix to upper-case keeps real keys clear of it — `sk-` forms break the identifier match at the hyphen — and requiring a non-`=` character after the separator keeps base64 padding clear of it too. It reports the same format failure as an illegal character rather than its own message: the reader's next move is identical either way, so a separate line would name a cause without changing what to do. ### Invariants belong at every layer; heuristics belong where the human is diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md index 75c98bd29c..84e5458675 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md @@ -22,7 +22,7 @@ Status: implemented 这一个断言覆盖了来源列出的全部输入:空值、首尾空白、中间空白、C0 控制字符、emoji、中文、全角标点。它同时正是造成 ByteString 失败的那条约束,所以两个 issue 收敛于同一个定义,而不是两个恰好相关的修复。 -第二条更窄的规则用于识别整行粘贴的环境变量:匹配 `^[A-Z][A-Z0-9_]*=` 或首尾成对引号的输入会被拒绝。把前缀限定为全大写可以让真实 Key 与之绝缘——`sk-` 这类形态会在连字符处中断标识符匹配。 +第二条更窄的规则用于识别整行粘贴的环境变量:匹配 `^[A-Z][A-Z0-9_]*=[^=]` 或首尾成对引号的输入会被拒绝。把前缀限定为全大写可以让真实 Key 与之绝缘——`sk-` 这类形态会在连字符处中断标识符匹配——而要求分隔符之后必须是非 `=` 字符,则让 base64 的 padding 也与之绝缘。它报出的是与非法字符相同的那条格式失败,而不是自己的一句:读到它的人下一步动作完全一样,因此单列一句只会点出一个原因,却不改变该怎么做。 ### 不变量属于每一层,启发式属于人所在的那一层 diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index 3db62f6c31..ca475596fd 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: e3328bb5fd2cf812b05dc26bf534226818132631 -README.zh.md: 20e40cc571a9123b50dfb28565c5562937e03189 +README.md: a9799916a91cb7416765c387b59c9172fa477666 +README.zh.md: e73100fa7d23a4025dffe471615401617a4af811 diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index e3328bb5fd..a9799916a9 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -8,7 +8,7 @@ Rows are the *configured* providers (their profile resolves in the owning namesp 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. -Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A typed API key is judged on its own field the same way: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A field holding only whitespace fails rather than being silently dropped, and a value shaped like a pasted `NAME=value` environment line or wrapped in matching quotes fails too; that paste-shape heuristic runs only in the browser, since a false positive in a resolver would leave the environment refusing the key as well. An empty field is not a failure at all: it means keep the stored key on an editor card, and authenticate some other way on a create card. A refused key blocks both the write and the endpoint interrogation, so the page never spends a round trip to be told what the field already says. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. +Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A typed API key is judged on its own field the same way: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A field holding only whitespace fails rather than being silently dropped, and a value shaped like a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure; that paste-shape heuristic runs only in the browser, since a false positive in a resolver would leave the environment refusing the key as well. An empty field is not a failure at all: it means keep the stored key on an editor card, and authenticate some other way on a create card. A refused key blocks both the write and the endpoint interrogation, so the page never spends a round trip to be told what the field already says. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. ## Model list and endpoint interrogation diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index 20e40cc571..e73100fa7d 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -8,7 +8,7 @@ 前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。 -每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定:trim 之后必须非空,且每个字符都是可打印 ASCII(`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm` 中 `normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。只含空白的输入框会失败,而不是被静默丢弃;形如整行粘贴的 `NAME=value` 环境变量或首尾成对引号包裹的值也会失败——该粘贴形状启发式只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。留空则完全不是失败:在编辑卡片上它意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 +每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定:trim 之后必须非空,且每个字符都是可打印 ASCII(`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm` 中 `normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。只含空白的输入框会失败,而不是被静默丢弃;形如整行粘贴的 `NAME=value` 环境变量或首尾成对引号包裹的值,会以同一条格式失败被拒绝——该粘贴形状启发式只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。留空则完全不是失败:在编辑卡片上它意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 ## 模型列表与端点询问 diff --git a/packages/client/ui-models/src/client/apiKey.ts b/packages/client/ui-models/src/client/apiKey.ts index 5fd1d22ee6..5e3aa692c8 100644 --- a/packages/client/ui-models/src/client/apiKey.ts +++ b/packages/client/ui-models/src/client/apiKey.ts @@ -22,8 +22,14 @@ const LEGAL_API_KEY = /^[\x21-\x7E]+$/ */ const ENV_LINE = /^[A-Z][A-Z0-9_]*=[^=]/ -/** Copy key naming why a typed key cannot be saved. */ -export type ApiKeyFailureKey = 'keyBlank' | 'keyIllegalCharacters' | 'keyLooksWrapped' +/** + * Copy key naming why a typed key cannot be saved. A wrapped paste reports the + * same format failure as an illegal character: the reader's next move is the + * same either way — look at the key and paste it again — so naming the two + * causes apart would spend the field's one line on a distinction that changes + * nothing about what to do. + */ +export type ApiKeyFailureKey = 'keyBlank' | 'keyIllegalCharacters' /** Whether a value is wrapped in one matching pair of quotes. */ function isQuoted(value: string): boolean { @@ -46,7 +52,7 @@ export function apiKeyFailure(draft: string): ApiKeyFailureKey | undefined { if (draft.length === 0) return undefined const value = draft.trim() if (value.length === 0) return 'keyBlank' - if (ENV_LINE.test(value) || isQuoted(value)) return 'keyLooksWrapped' + if (ENV_LINE.test(value) || isQuoted(value)) return 'keyIllegalCharacters' if (!LEGAL_API_KEY.test(value)) return 'keyIllegalCharacters' return undefined } diff --git a/packages/client/ui-models/src/client/locales.ts b/packages/client/ui-models/src/client/locales.ts index 85f7c14f97..336bf498f5 100644 --- a/packages/client/ui-models/src/client/locales.ts +++ b/packages/client/ui-models/src/client/locales.ts @@ -49,7 +49,6 @@ export const en = { keyBlank: 'Enter the API key, or leave the field empty to keep the stored one.', keyBlankNew: 'Enter the API key, or leave the field empty if this provider authenticates another way.', keyIllegalCharacters: 'This API key is not in a valid format. Please check it.', - keyLooksWrapped: 'Paste only the key itself — not a NAME=value line, and without surrounding quotes.', modelIdRequired: 'Model ID is required.', modelIdDuplicate: 'Model ID must be unique.', modelNameInvalid: 'Display name cannot be empty.', @@ -137,7 +136,6 @@ export const zh: typeof en = { keyBlank: '请输入 API 密钥;留空则保持已存储的密钥。', keyBlankNew: '请输入 API 密钥;若该提供方以其他方式鉴权,可以留空。', keyIllegalCharacters: '该 API 密钥格式错误,请检查。', - keyLooksWrapped: '请只粘贴密钥本身——不要带 NAME=value 整行,也不要带引号。', modelIdRequired: '模型 ID 不能为空。', modelIdDuplicate: '模型 ID 不能重复。', modelNameInvalid: '显示名称不能为空。', diff --git a/packages/client/ui-models/tests/components.spec.tsx b/packages/client/ui-models/tests/components.spec.tsx index 7228d472cd..5bb2da387c 100644 --- a/packages/client/ui-models/tests/components.spec.tsx +++ b/packages/client/ui-models/tests/components.spec.tsx @@ -1121,8 +1121,8 @@ describe('apiKeyFailure', () => { ['double quotes', '"sk-abc"'], ['single quotes', '\'sk-abc\''], ['backticks', '`sk-abc`'], - ])('fails %s as wrapped', (_label, draft) => { - expect(apiKeyFailure(draft)).toBe('keyLooksWrapped') + ])('fails %s as a format failure', (_label, draft) => { + expect(apiKeyFailure(draft)).toBe('keyIllegalCharacters') }) it('needs a matching closing quote before it calls a value wrapped', () => { diff --git a/packages/client/ui-models/tests/provider-form.spec.tsx b/packages/client/ui-models/tests/provider-form.spec.tsx index 5d505386e6..11271561b8 100644 --- a/packages/client/ui-models/tests/provider-form.spec.tsx +++ b/packages/client/ui-models/tests/provider-form.spec.tsx @@ -987,7 +987,7 @@ describe('API key field', () => { fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'OPENAI_API_KEY=sk-abc' } }) - expect(screen.getByText(en.keyLooksWrapped)).toBeTruthy() + expect(screen.getByText(en.keyIllegalCharacters)).toBeTruthy() expect(buttonNamed(en.apply).disabled).toBe(true) }) From bb43ff4f37aad13729f03568f595b0415aed083d Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Fri, 7 Aug 2026 15:26:42 +0800 Subject: [PATCH 58/67] feat(ui): make a session that cannot send refuse to accept one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A default naming a route the Models page has since removed left the composer saying 选择模型 while the input still accepted a message, which then failed inside the adapter mid-turn. `session.prompt` now refuses with `model-unavailable` before opening a turn. That is the enforcement boundary: the method stays callable no matter what a client disables. `session.models` reports the same fact as `routable`, and ui-model pushes a block through the new `ctx.conversation.blocks` registry so the bar renders the disabled textarea it already renders without a workspace, carrying the blocker's own reason. The push direction is forced — ui-model already depends on ui-conversation, so ui-conversation cannot read it back. The gate is `routable`, not "matches no advertised group": catalog membership is advisory, so a route serving a model it stopped advertising is missing from the groups yet perfectly usable, and `null` before the first load never blocks so a slow Host cannot lock a working composer. The scaffold gains a route-only adapter for fixture-less keyless scenarios. Registering zero providers is a test artifact — every product composition mounts one — and the goldens that froze the seat's fallback label now show the model those scenarios actually route to. --- ...default-model-follows-the-picker.i18n.yaml | 4 +- ...-08-07-default-model-follows-the-picker.md | 12 +- ...-07-default-model-follows-the-picker.zh.md | 12 +- apps/web/tests/default-model.e2e.ts | 67 +++++++++-- apps/web/tests/default-model.overlay.yml | 8 ++ apps/web/tests/message-actions.e2e.ts | 2 +- apps/web/tests/scaffold.ts | 54 +++++++++ apps/web/tests/seeded-history.e2e.ts | 8 +- .../snapshots/bash-abort-row/ui.expected.md | 4 +- .../lifecycle-chrome/plan-active.expected.md | 4 +- .../markdown-cjk-strong/ui.expected.md | 4 +- .../snapshots/markdown-images/ui.expected.md | 4 +- .../markdown-inline-code-links/ui.expected.md | 4 +- .../snapshots/math-rendering/ui.expected.md | 4 +- .../snapshots/message-actions/ui.expected.md | 4 +- .../seeded-history/command-row.expected.md | 4 +- .../snapshots/seeded-history/ui.expected.md | 4 +- docs/config-catalog.md | 27 ++--- .../client/connection/src/client/fixture.ts | 3 + packages/client/connection/tests/fake-api.ts | 1 + packages/client/runtime/tests/fake-api.ts | 1 + .../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 | 16 ++- .../src/client/contract/slots.ts | 9 +- .../src/client/input/blocks.ts | 77 +++++++++++++ .../ui-conversation/src/client/service.ts | 16 ++- .../src/client/skeleton/ConversationRoot.tsx | 13 ++- .../tests/service-orchestration.spec.ts | 3 + .../ui-conversation/tests/skeleton.spec.tsx | 28 +++++ packages/client/ui-model/README.i18n.yaml | 4 +- packages/client/ui-model/README.md | 2 + packages/client/ui-model/README.zh.md | 2 + .../client/ui-model/src/client/directory.ts | 23 +++- packages/client/ui-model/src/client/index.ts | 6 +- .../client/ui-model/src/client/locales.ts | 2 + .../client/ui-model/src/client/service.ts | 28 ++++- .../ui-model/tests/browser-plugin.spec.ts | 73 +++++++++++- .../ui-model/tests/model-select.spec.tsx | 1 + 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 | 80 +++++++------ .../ui-models/tests/provider-form.spec.tsx | 61 +++++++++- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 6 +- packages/host/apiproxy/README.zh.md | 6 +- packages/host/apiproxy/src/api-proxy.ts | 56 ++++++++- .../host/apiproxy/src/api/sessions.schema.ts | 1 + packages/host/apiproxy/src/api/sessions.ts | 9 ++ packages/host/apiproxy/src/index.ts | 76 ++++++------ .../apiproxy/tests/api-proxy-config.spec.ts | 21 +++- .../tests/api-proxy-default-route.spec.ts | 108 ++++++++++++++++++ .../apiproxy/tests/api-proxy-models.spec.ts | 31 +++++ .../apiproxy/tests/client-handler.spec.ts | 1 + .../host/apiproxy/tests/fetch-carrier.spec.ts | 1 + .../host/apiproxy/tests/rpc-schemas.spec.ts | 7 +- 58 files changed, 859 insertions(+), 163 deletions(-) create mode 100644 apps/web/tests/default-model.overlay.yml create mode 100644 packages/client/ui-conversation/src/client/input/blocks.ts create mode 100644 packages/host/apiproxy/tests/api-proxy-default-route.spec.ts 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 ba3917c8b7..40eb59e98f 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: 5174b224a17728b65f7fd69d7f72388d50e8e825 -2026-08-07-default-model-follows-the-picker.zh.md: 6ead561b928572a479f2c7b19e409b3845363ed6 +2026-08-07-default-model-follows-the-picker.md: 4142b3aea6a807001831df62c2038ddf57bbd6ad +2026-08-07-default-model-follows-the-picker.zh.md: c3566567781edac12cd9269d63f86528139c8796 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 5174b224a1..4142b3aea6 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 @@ -10,7 +10,7 @@ The route a new session started from was frozen into the gateway's composition e ## 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. +`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. `reasoningEffort` is the mirror case: it lives in the section but NOT in the plugin config, because the seam merges the user layer over the composition entry per field and an absent key cannot override a present one. A composition-set effort would therefore survive every later switch to a model without one — precisely the stranding the wholesale `replace` exists to prevent. Effort is a per-model fact anyway; a deployment default for it belongs on the adapter profile, which resolves per model. `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. @@ -24,6 +24,16 @@ The stored route is not validated against the registry. A default naming a route `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. +## Follow-up: blocking a session that cannot send + +A default naming a route the Models page has since removed leaves the composer saying "Select model" while the input still accepts a message, which then fails inside the adapter mid-turn. Two changes close it. + +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 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. + ## 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. 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 6ead561b92..c356656778 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 @@ -10,7 +10,7 @@ Status: implemented ## 决定 -`ApiProxyService` 把自己的 `{provider, model, reasoningEffort?}` 切片注册为 `api-gateway` 设置段:组合条目是 `base` 层,`settings.yaml` 把用户的选择叠加其上。`workspaceRoot` 留在段外——它是启动器事实,不是偏好。段 schema 从 `static Config` 里挑出来而不是重述一遍,因为配置目录生成器是静态读取那个字面量的,展开语法会让它失败。 +`ApiProxyService` 把自己的 `{provider, model, reasoningEffort?}` 切片注册为 `api-gateway` 设置段:组合条目是 `base` 层,`settings.yaml` 把用户的选择叠加其上。`workspaceRoot` 留在段外——它是启动器事实,不是偏好。`reasoningEffort` 则是镜像的一例:它在段里、但**不在**插件配置里,因为 seam 是按字段把用户层合并到组合条目之上的,缺席的键覆盖不了存在的键。组合层设的推理等级因此会在此后每一次切到不支持推理的模型时继续存活——正是整段 `replace` 想要杜绝的那种滞留。何况推理等级本就是按模型的事实,它的部署级默认值属于适配器 profile,那里是按模型解析的。 `session.selectModel` 把被接受的切换记录为新的默认值。没有另一个单独的手势:在输入框切模型**就是**选定默认值的方式。写入用 `replace` 而非 `update`——切到一个不支持推理的模型必须清掉已存的等级,而合并补丁会把它滞留下来,让下一个会话在它上面失败。存储失败只记日志,不撤销这次切换(它对自己所在的会话已经生效);没有设置提供方的部署保留组合条目,切换只停留在进程内。 @@ -24,6 +24,16 @@ Status: implemented `ApiProxyDefaults` 形状变了,约 40 处测试构造点随之更新。`host.describe` 现在报告的是活的默认值而非捕获的快照,这本就是它一直想表达的含义。用户一旦切换模型,`settings.yaml` 就会多出一个 `api-gateway:` 段;`api-gateway` 这个 namespace 刻意**没有**加进网关的暴露名单,因此设置页既不读也不写它——模型选择器就是它的编辑器。 +## 后续:让发不出消息的会话禁止输入 + +默认值指向一条模型页已删除的路由时,编辑器显示「选择模型」,输入框却仍接受消息,然后这一轮在适配器内部失败。两处改动关掉这个口子。 + +宿主拒绝。`session.prompt` 检查是否有适配器服务该会话的路由,在开启轮次之前就以 `model-unavailable` 应答。这是执行边界:客户端禁用编辑器只是提示性设计,这个方法始终可被调用。 + +编辑器变惰性。`session.models` 报告 `routable`,ui-model 经新的 `ctx.conversation.blocks` 注册表推送一个 block;输入栏渲染的仍是它在没有 Workspace 时就会渲染的那个禁用 textarea,只是把抬起方自己的本地化理由作为 placeholder。推送方向是被迫的——ui-model 本就依赖 ui-conversation,因此 ui-conversation 读不回去。 + +闸门是 `routable`,**不是**「当前目标匹配不到任何已公布分组」。目录成员关系按设计是咨询性的:一条仍在服务、只是不再公布该模型的路由不在分组里,却完全可用,在那里阻断会破坏一种受支持的配置(对一条活着的路由收窄 `models` 列表)。`routable` 在客户端还是三值的——首次加载之前或加载失败之后的 `null` 绝不阻断,因此慢的或够不着的宿主锁不死一个本来能用的编辑器。 + ## 考虑过的替代方案 - **存下来的路由未注册时回落到组合条目。** 否决:那样输入框会显示出厂的 DeepSeek 模型而不是提示选择,既是静默切到用户没选的提供方,也与要求的行为正好相反。 diff --git a/apps/web/tests/default-model.e2e.ts b/apps/web/tests/default-model.e2e.ts index 0ba81e3ca0..9ff6198c3a 100644 --- a/apps/web/tests/default-model.e2e.ts +++ b/apps/web/tests/default-model.e2e.ts @@ -4,11 +4,14 @@ // 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. +// is no fixture and a stray stream would fail loud on the open seam. Both +// routes are declared host-side (not through the UI, which has its own +// scenario) through the pi-ai adapter the shipped tree already mounts: a +// fixture-less scaffold registers no adapter at all, so the routes the +// picker offers — and the one the composer must start on — have to come from +// somewhere, and settings profiles are the product's own way to add them. import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' @@ -18,7 +21,13 @@ 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. */ +/** Points the shipped `api-gateway` default at this scenario's own route. */ +const OVERLAY = fileURLToPath(new URL('./default-model.overlay.yml', import.meta.url)) + +/** The route this scenario starts on, patched over the shipped default. */ +const START_ROUTE = 'origin-gateway' +const START_MODEL = 'origin-large' +/** The route the switch lands on, which then becomes the saved default. */ const ROUTE = 'acme-gateway' const MODEL = 'acme-large' @@ -49,12 +58,19 @@ describe('web e2e: the composer model switch is the default for later sessions', } 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. + scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY }) + // Two routes so the picker has somewhere to start and somewhere to go. + // 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: { + [START_ROUTE]: { + displayName: 'Origin Gateway', + api: 'openai-completions', + baseURL: 'https://gateway.origin.example/v1', + models: [{ id: START_MODEL, name: 'Origin Large' }], + }, [ROUTE]: { displayName: 'Acme Gateway', api: 'openai-completions', @@ -84,7 +100,7 @@ describe('web e2e: the composer model switch is the default for later sessions', // 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' } }, + header: { config: { provider: START_ROUTE, model: START_MODEL } }, reason: 'initial', }) @@ -108,8 +124,35 @@ describe('web e2e: the composer model switch is the default for later sessions', 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(await currentOf(loggedId)).toEqual({ provider: START_ROUTE, model: START_MODEL }) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('goes inert when the route the default names stops being served', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-default-model-blocked')) + const box = page.locator('textarea[data-input-phase], textarea').first() + await expect.poll(async () => box.isEnabled(), { timeout: 10_000 }).toBe(true) + + // What removing the provider on the Models page leaves behind: the saved + // default still names the route, and nothing serves it any more. + // `replace`, not `update`: a merge patch of `{providers: {}}` leaves every + // stored profile in place. + await scaffold.ctx.settings.replace(settingsNamespace('llm-pi-ai'), { providers: {} }) + + await expect.poll(async () => box.isEnabled(), { timeout: 15_000 }).toBe(false) + expect(await box.getAttribute('placeholder')).toBe('当前模型不可用,请先选择模型') + + // The block is an affordance; the refusal is the Host's. A client that + // never disabled anything still cannot start a turn on a dead route. + const refused = await scaffold.ctx.apiProxy.sessions.prompt({ + rpcId: 'default-model-refused' as never, + payload: { + sessionId: SessionId(await createSession('default-model-refusal')), + mode: 'queue' as const, + content: [{ type: 'text' as const, text: 'hi' }], + }, + }) + expect(refused.result).toMatchObject({ ok: false, error: { code: 'model-unavailable' } }) expect(tripwire.pageErrors).toEqual([]) }, 60_000) }) diff --git a/apps/web/tests/default-model.overlay.yml b/apps/web/tests/default-model.overlay.yml new file mode 100644 index 0000000000..540e4891d6 --- /dev/null +++ b/apps/web/tests/default-model.overlay.yml @@ -0,0 +1,8 @@ +# The fixture-less web scaffold registers no adapter, so the shipped +# deepseek-official default would be a route nothing serves — which the +# composer now correctly refuses to type into. This scenario declares its own +# pi-ai routes and starts the default on one of them. +- id: api-gateway + config: + provider: origin-gateway + model: origin-large diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index 6149a66df1..6c4f450529 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -126,7 +126,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => { it.skipIf(MODE === 'record')('matches the conversation aria golden with IconActions and clocks', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions-aria')) - await page.getByRole('button', { name: 'Select model', exact: true }) + await page.getByRole('button', { name: /^Select model, current/ }) .waitFor({ timeout: 10_000 }) // Keep a footer focused so opacity-hidden actions stay in the a11y tree // as an active/focused control during the capture. diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index c84b25a332..1db06f59ef 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -45,6 +45,10 @@ import { WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION, } from '@deepseek-ai/dsh-client-ui-settings-general' import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import { LlmAdapter } from '@deepseek-ai/dsh-llm' +import type { + LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, StreamChunk, +} from '@deepseek-ai/dsh-llm' import type { ReplayHandle } from '@deepseek-ai/dsh-llm-replay' import { installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay' import SessionStore, { @@ -93,6 +97,46 @@ const REPLAY_PROVIDERS = [{ models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: 128_000 }], }] +/** + * The routes a shipped composition always has, with no ability to stream. + * A fixture-less keyless scenario issues no model calls, but its tree must + * still answer `listProviders()` — surfaces legitimately gate on whether any + * adapter serves a session's route, and an empty registry is a test artifact, + * not a product state. + */ +class RouteOnlyAdapter extends LlmAdapter { + constructor(private readonly providers: typeof REPLAY_PROVIDERS) { + super() + } + + override providerInfo(provider: string): LlmProviderInfo { + return { id: provider, name: this.providers.find(entry => entry.id === provider)?.name ?? provider } + } + + override listModels(provider: string): Promise<readonly LlmModelInfo[]> { + return Promise.resolve((this.providers.find(entry => entry.id === provider)?.models ?? []) + .map(model => ({ provider, id: model.id, name: model.name }))) + } + + override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> { + const listed = this.providers.find(entry => entry.id === provider)?.models + .find(entry => entry.id === model) + return Promise.resolve({ + provider, + id: model, + name: listed?.name ?? model, + ...listed?.contextWindow === undefined ? {} : { contextWindow: listed.contextWindow }, + }) + } + + override async *stream(): AsyncIterable<StreamChunk> { + throw new Error( + 'web e2e scaffold: a model call was issued by a scenario that declared no replay fixture' + + ' — pass replayFixture, or keep the scenario free of model calls', + ) + } +} + function replayProviders(contextWindow: number | undefined): typeof REPLAY_PROVIDERS { if (contextWindow === undefined) return REPLAY_PROVIDERS return REPLAY_PROVIDERS.map(provider => ({ @@ -390,6 +434,16 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We ...(options.replayChildFixtures === undefined ? {} : { childFiles: options.replayChildFixtures }), ...(options.paceMs === undefined ? {} : { paceMs: options.paceMs }), }) + } else if (mode !== 'record' && options.deepSeekMissingCredential !== true) { + // No fixture and no shipped adapter would leave the tree with ZERO + // provider routes — a state no product composition has, and one the + // composer now correctly refuses to type into. Register the same routes + // a fixture would, with streaming that still fails loud: the scenario + // issues no model calls, and one that slipped in must not pass quietly. + ctx.effect(() => ctx.llm.registerAdapter( + replayProviders(options.replayContextWindow).map(provider => provider.id), + new RouteOnlyAdapter(replayProviders(options.replayContextWindow)), + ), 'web e2e scaffold: route-only adapter') } } catch (error) { if (process.cwd() !== originalCwd) process.chdir(originalCwd) diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index f9ef1055d1..799503ef1c 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -280,10 +280,10 @@ describe('web e2e: seeded history renders through cold resume', () => { it.skipIf(MODE === 'record')('matches the historical conversation aria golden', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-aria')) - // This scenario deliberately leaves the LLM seam open to prove zero - // model calls. History still restores the routed id, but without an - // advertised catalog row the selector prompts for a listed replacement. - await page.getByRole('button', { name: 'Select model', exact: true }) + // This scenario issues zero model calls — the scaffold's route-only + // adapter serves the catalog and refuses to stream — so history restores + // the routed id and the seat resolves it against an advertised row. + await page.getByRole('button', { name: /^Select model, current/ }) .waitFor({ timeout: 10_000 }) const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') diff --git a/apps/web/tests/snapshots/bash-abort-row/ui.expected.md b/apps/web/tests/snapshots/bash-abort-row/ui.expected.md index d626830553..94b8bf80e9 100644 --- a/apps/web/tests/snapshots/bash-abort-row/ui.expected.md +++ b/apps/web/tests/snapshots/bash-abort-row/ui.expected.md @@ -23,8 +23,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 · 1 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 10 tok · Output 10 tok 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..968083919a 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md @@ -30,8 +30,8 @@ - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Plan mode on, press to turn off": Plan -- button "Select model": - - text: Select model +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] - text: Details 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 5a182175ee..dd566c17d5 100644 --- a/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md +++ b/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md @@ -42,8 +42,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 · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok diff --git a/apps/web/tests/snapshots/markdown-images/ui.expected.md b/apps/web/tests/snapshots/markdown-images/ui.expected.md index b7d39d5ac0..21e84e000d 100644 --- a/apps/web/tests/snapshots/markdown-images/ui.expected.md +++ b/apps/web/tests/snapshots/markdown-images/ui.expected.md @@ -21,8 +21,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 · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok 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 cc255cf0b0..221294ad6b 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 @@ -33,8 +33,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 · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok diff --git a/apps/web/tests/snapshots/math-rendering/ui.expected.md b/apps/web/tests/snapshots/math-rendering/ui.expected.md index 18bc3b791f..4880d108e0 100644 --- a/apps/web/tests/snapshots/math-rendering/ui.expected.md +++ b/apps/web/tests/snapshots/math-rendering/ui.expected.md @@ -37,8 +37,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 · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md index 0adabf54d8..38430d636e 100644 --- a/apps/web/tests/snapshots/message-actions/ui.expected.md +++ b/apps/web/tests/snapshots/message-actions/ui.expected.md @@ -45,8 +45,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: 2 turns · 3 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 98% Input 7.8K tok · Output 103 tok diff --git a/apps/web/tests/snapshots/seeded-history/command-row.expected.md b/apps/web/tests/snapshots/seeded-history/command-row.expected.md index 6e8d1eb0f7..fe58587913 100644 --- a/apps/web/tests/snapshots/seeded-history/command-row.expected.md +++ b/apps/web/tests/snapshots/seeded-history/command-row.expected.md @@ -44,8 +44,8 @@ - button "Commands": - img - 'button "Access mode, current: Read Only"': Read Only -- 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 98% Input 15.8K tok · Output 135 tok diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index d0ce89bc90..ce2921eac2 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -42,8 +42,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 98% Input 15.8K tok · Output 135 tok diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 7842c1a477..97b4f8fb8d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -577,28 +577,29 @@ Source: [`packages/hooks/hooks-codex/src/index.ts:44`](../packages/hooks/hooks-c Requires: `agents` · `directoryPicker` · `llm` · `sessions` · `subagents` · `sessionQuery` · `tools` · `userInteraction` · `workspace` ```ts config-catalog -/** 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 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. + * 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 DefaultRouteSettings { +export interface Config { /** Default provider route for created agents. */ provider: string /** Default model id. */ model: string - /** Default reasoning effort; absence preserves the adapter/provider default. */ - reasoningEffort?: string + /** Parent directory for name-created Workspaces; defaults to the Host cwd. */ + workspaceRoot?: string } ``` -Source: [`packages/host/apiproxy/src/index.ts:64`](../packages/host/apiproxy/src/index.ts) +Source: [`packages/host/apiproxy/src/index.ts:67`](../packages/host/apiproxy/src/index.ts) ## `@deepseek-ai/dsh-host-directory-picker-browse` diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 9559b5e198..59b8ff9313 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1975,6 +1975,9 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { models: request => ok(request, { current: modelTargets.get(request.payload.sessionId) ?? { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + // The fixture's routes all serve; a surface exercising the blocked + // posture drives it through its own stub. + routable: true, groups: fixtureModelGroups(), failures: [], }), diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index e5eb42695d..cc1062843e 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -59,6 +59,7 @@ export class FakeApiClient implements IApiClient { onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({ current: { provider: 'deepseek-official', model: 'deepseek-chat' }, + routable: true, groups: [], failures: [], })) diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index e50574d102..b6f2884837 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -73,6 +73,7 @@ export class FakeApiClient implements IApiClient { onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({ current: this.defaultModel, + routable: true, groups: [{ id: 'deepseek-official', name: 'DeepSeek', diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index f5ad20c3fc..3af7b2fd75 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: a75f25d8669cd688795842a655106e0e27bb7173 -README.zh.md: f0d744c31020730857d210d75749b907dbffca08 +README.md: 392f9956b33df88a5e9664a58de27d85fc0457d1 +README.zh.md: 6b0429a302475f84a7ce9b1cdc9fd47d90d6dba3 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index a75f25d866..392f9956b3 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -8,6 +8,8 @@ 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. + 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: <active id>`), 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. Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The runtime manager projects every approval or question wait through `SessionSummary.pendingInteraction`, including sessions never instantiated; `ui-workspace` owns its sidebar presentation. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission <preset>` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index f0d744c310..6b0429a302 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -8,6 +8,8 @@ 常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `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 是更靠前的前提。 + 视图环是一个 slot:严格会话主体注册在 `children` 表中声明 Session scope 的 `'conversation.view'` 列表,并通过自身的 renderSlot share 渲染活跃配置项(`only: <active id>`);视图标签页则从注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包自身的配置项;ui-trajectory 等插件通过 `ctx.slots.register` 贡献标签页,每个视图负责自己的 chrome。 会话页头会在标题旁声明并渲染 Session scope 的 `'conversation.session.header.actions'` 列表,使功能插件无需进入骨架即可贡献控件。编辑器链的 currency 包含当前对话 `session`;ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send,因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 6bc9068cfc..78dc94fbfa 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -15,6 +15,8 @@ import { resolveToolPath } from './contract/tool-call-model.ts' import { createChatStore } from './stores.ts' import { ConversationService } from './service.ts' import type { IConversation } from './service.ts' +import { ComposerBlockRegistry } from './input/blocks.ts' +import type { ComposerBlock } from './input/blocks.ts' import { InputHub } from './input/hub.ts' import { ComposerSubmissionPolicy } from './input/submission-policy.ts' import { InputBar } from './skeleton/InputBar.tsx' @@ -54,6 +56,11 @@ const ABSENT_NOTICES = { getSnapshot: (): InputNotice | null => null, subscribe: () => () => {}, } +/** No session, therefore nothing to block; same one-identity rule as above. */ +const ABSENT_BLOCK = { + getSnapshot: (): ComposerBlock | undefined => undefined, + subscribe: () => () => {}, +} const EMPTY_LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map() const ABSENT_LEXICON = { getSnapshot: () => EMPTY_LEXICON, @@ -133,6 +140,12 @@ export function apply(ctx: Context): void { // ctx.conversation.input by the service below sharing this one instance). const inputHub = new InputHub(ctx) + // The composer-block registry: a plugin that knows a session cannot send — + // ui-model, when no adapter serves the session's route — raises a block + // here, and the bar reads its own session's store. It cannot flow the other + // way: this package must not import the plugins that would know. + const composerBlocks = new ComposerBlockRegistry() + // Decision 19/20: the input machine feeds every session-scope slot // component through the standard provide channel — the 'input' hook plus // the two public actions. Materialization is the shell creation trigger @@ -167,6 +180,7 @@ export function apply(ctx: Context): void { 'conversation.hero.workspace': { kind: 'single', scope: 'root' }, }, inject: (sessionId: SessionId | undefined): ConversationInjected => ({ + hooks: { composerBlock: sessionId === undefined ? ABSENT_BLOCK : composerBlocks.storeFor(sessionId) }, selectWorkspace: async (workspaceId) => { const nextId = await workspaces.connectWorkspace(workspaceId) if (sessionId !== undefined && nextId !== sessionId) { @@ -351,7 +365,7 @@ export function apply(ctx: Context): void { // registers itself as `conversation` and lives on its own child fiber. // Presentation registrants depend directly on their slot declarations; // this service remains only where conversation actions are required. - ctx.plugin(ConversationService, { input: inputHub }) + ctx.plugin(ConversationService, { input: inputHub, blocks: composerBlocks }) // The bash sample rides the same declaration seam, in third-party posture // (ToolRow-matching Bash · {description} chrome). diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index a84b4a3bf0..4c3a1546c9 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -5,6 +5,7 @@ import type { } 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 {} from '@deepseek-ai/dsh-client-ui-layout/client' +import type { ComposerBlock } from '../input/blocks.ts' import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts' import type { createChatStore } from '../stores.ts' import type { ComposerSubmitGesture, InputSubmitMode } from './composer-submission.ts' @@ -223,6 +224,12 @@ export interface ConversationInjected { * When a blank session is already current, carry its draft to the target. */ selectWorkspace: (workspaceId: WorkspaceId) => Promise<void> + /** + * Framework-bound sources. `composerBlock` is this session's block when a + * plugin raised one; the reason is the blocker's own localized copy, which + * the root renders as the inert composer's placeholder. + */ + hooks: { composerBlock: ObservableSnapshot<ComposerBlock | undefined> } } /** Business callbacks injected into the strict Session body seat. */ @@ -356,7 +363,7 @@ export type ConversationSlotProps = | 'conversation.input.left' | 'conversation.input.right' | 'conversation.hero.workspace' > - & ConversationInjected + & InjectFace<ConversationInjected> & PropsLocale<'conversation'> /** Full strict-session body props: per-session store, view ring, and draft mirror. */ diff --git a/packages/client/ui-conversation/src/client/input/blocks.ts b/packages/client/ui-conversation/src/client/input/blocks.ts new file mode 100644 index 0000000000..3d30f54094 --- /dev/null +++ b/packages/client/ui-conversation/src/client/input/blocks.ts @@ -0,0 +1,77 @@ +/** + * Composer blocks: the one way another plugin stops a session's input. + * + * The composer cannot read the plugins that would know — the dependency runs + * ui-model → ui-conversation, never back — so a blocker pushes here and the + * bar reads its own session's store. A block carries the localized reason it + * exists, because the plugin that raised it owns that copy; the composer only + * knows how to render an inert textarea with a placeholder, exactly as it + * already does for a session with no workspace. + * + * This is an affordance, not enforcement: the Host refuses a prompt it cannot + * route regardless of what any client disables. + */ + +import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionId, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' + +/** Why one session's composer is inert. */ +export interface ComposerBlock { + /** + * Localized placeholder replacing the composer's own, owned by the plugin + * that raised the block. + */ + readonly reason: string +} + +/** The registry face other plugins reach through `ctx.conversation.blocks`. */ +export interface ComposerBlocks { + /** + * Raise or clear this session's block. Idempotent: setting a block equal to + * the current one, or clearing an absent one, notifies nobody. + * @param sessionId - the session whose composer is affected. + * @param block - the block to raise, or undefined to clear it. + */ + set(sessionId: SessionId, block: ComposerBlock | undefined): void + /** + * The store the composer subscribes to for one session. Created on first + * read from either side, so a blocker may raise a block before the session's + * composer mounts and the composer still sees it. + * @param sessionId - the session to observe. + * @returns that session's block store (undefined value = not blocked). + */ + storeFor(sessionId: SessionId): SnapshotStore<ComposerBlock | undefined> + /** + * Drop one session's store. The session scope's disposer calls this; a + * blocker never needs to. + * @param sessionId - the session being torn down. + */ + forget(sessionId: SessionId): void +} + +/** The per-session composer-block registry (one instance per plugin fiber). */ +export class ComposerBlockRegistry implements ComposerBlocks { + private readonly stores = new Map<SessionId, SnapshotStore<ComposerBlock | undefined>>() + + /** @inheritdoc */ + set(sessionId: SessionId, block: ComposerBlock | undefined): void { + const store = this.storeFor(sessionId) + const current = store.getSnapshot() + if (current?.reason === block?.reason) return + store.set(block) + } + + /** @inheritdoc */ + storeFor(sessionId: SessionId): SnapshotStore<ComposerBlock | undefined> { + const existing = this.stores.get(sessionId) + if (existing !== undefined) return existing + const created = createSnapshotStore<ComposerBlock | undefined>(undefined) + this.stores.set(sessionId, created) + return created + } + + /** @inheritdoc */ + forget(sessionId: SessionId): void { + this.stores.delete(sessionId) + } +} diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index decca00e01..be088d61ef 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -14,6 +14,7 @@ import type { Context } from 'cordis' // method) instead of the standalone helper. import type { ISessions, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { QueueAction, QueueItemId } from './contract/queue.ts' +import type { ComposerBlocks } from './input/blocks.ts' import type { InputService } from './input/contract.ts' /** @@ -24,6 +25,11 @@ import type { InputService } from './input/contract.ts' export interface IConversation { /** The per-session input machine registry (InputService face). */ readonly input: InputService + /** + * The per-session composer-block registry: how a plugin the composer + * cannot import makes a session's input inert with its own reason. + */ + readonly blocks: ComposerBlocks /** * Send a prompt into the caller scope's session (queued turn). * @param text - prompt text, sent verbatim as one text block. @@ -53,16 +59,20 @@ export interface IConversation { export class ConversationService extends Service implements IConversation { /** The per-session input machine registry (InputService face, design §5.2). */ readonly input: InputService + /** The per-session composer-block registry. */ + readonly blocks: ComposerBlocks /** * @param ctx - owning root context (the plugin apply context; the service * registers itself and follows that fiber's lifetime). - * @param config - carries the InputService instance constructed by the - * plugin apply (the same InputHub the slot inject factories close over). + * @param config - carries the InputService and composer-block registry + * constructed by the plugin apply (the same instances the slot inject + * factories close over). */ - constructor(ctx: Context, config: { input: InputService }) { + constructor(ctx: Context, config: { input: InputService; blocks: ComposerBlocks }) { super(ctx, 'conversation') this.input = config.input + this.blocks = config.blocks } /** diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index bc5f7bdc01..377b4de3ec 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -13,7 +13,7 @@ import css from './ConversationRoot.module.css' export type ConversationRootProps = ConversationSlotProps export function ConversationRoot({ - sessionId, useSession, useSessions, useWorkspaces, useInput, + sessionId, useSession, useSessions, useWorkspaces, useInput, useComposerBlock, renderSlot, renderSlotChain, selectWorkspace, t, }: ConversationRootProps) { const openState = useSession(s => s.openState) @@ -24,6 +24,9 @@ export function ConversationRoot({ const cwd = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.cwd) const summaryBlank = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.blank) const workspaces = useWorkspaces(s => s) + // A plugin this package cannot import (ui-model) says this session cannot + // send; its reason is already localized by whoever raised it. + const composerBlock = useComposerBlock(block => block) const [pickerOpen, setPickerOpen] = useState(false) const [pendingWorkspaceId, setPendingWorkspaceId] = useState<WorkspaceId | undefined>() @@ -126,11 +129,17 @@ export function ConversationRoot({ // bar is ONE session-maybe slot rendered unconditionally — inert is a prop, // not a different tree, so the textarea DOM survives the transition. const inert = sessionId === undefined || (hero && chipTitle === undefined) + // A raised block is the same inert posture with the blocker's own reason: + // one disabled textarea, never a second tree. The no-workspace state wins + // when both hold — picking a workspace is the earlier prerequisite. + const blocked = !inert && composerBlock !== undefined const inputBar = renderSlot('conversation.composer.bar', { variant: hero ? 'hero' : 'composer', ...(inert ? { disabled: true, placeholder: t('placeholder.workspace') } - : hero ? { placeholder: t('placeholder.hero') } : {}), + : blocked + ? { disabled: true, placeholder: composerBlock.reason } + : hero ? { placeholder: t('placeholder.hero') } : {}), overlay: renderSlot('conversation.input.overlay', {}), leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone), rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone), diff --git a/packages/client/ui-conversation/tests/service-orchestration.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.spec.ts index ebd51f8408..0dd742a5fe 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.spec.ts @@ -7,6 +7,7 @@ import { Context } from 'cordis' import { describe, expect, it, vi } from 'vitest' import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { ComposerBlockRegistry } from '../src/client/input/blocks.ts' import { InputHub } from '../src/client/input/hub.ts' async function bench() { @@ -23,6 +24,7 @@ async function bench() { // factories); the bench passes its own instance explicitly. const fiber = runtime.ctx.plugin(ConversationService, { input: new InputHub(runtime.ctx), + blocks: new ComposerBlockRegistry(), }) await fiber.await() const root = runtime.ctx.get('conversation') as ConversationService @@ -86,6 +88,7 @@ describe('ConversationService', () => { const bare = new Context() await bare.plugin(ConversationService, { input: new InputHub(bare), + blocks: new ComposerBlockRegistry(), }).await() const orphan = bare.get('conversation') as ConversationService await expect(orphan.send('x')).rejects.toThrow(/sessions service unavailable/) diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index ba2e75f5f7..e0872f1539 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -91,6 +91,8 @@ function mount( omitSummaryRow?: boolean /** Classify the selected child as a subagent instead of an ordinary fork. */ summaryOrigin?: 'subagent' + /** A composer block another plugin raised for this session. */ + composerBlock?: { reason: string } } = {}, ) { const root = sid('root') @@ -224,6 +226,7 @@ function mount( useSessions: bindSnapshotSelector(sessions), useWorkspaces: bindSnapshotSelector(workspaces), useProjection: (() => undefined), + useComposerBlock: select => select(options.composerBlock), useInput, inputActions, renderSlot, @@ -248,6 +251,31 @@ describe('Hero chrome', () => { }) describe('ConversationRoot resident composer', () => { + it('renders the composer inert with the blocker\u2019s own reason', () => { + const b = mount(conversationSnapshot(), undefined, undefined, { + composerBlock: { reason: 'select a model first' }, + }) + const box = b.view.getByRole('textbox') as HTMLTextAreaElement + // One disabled textarea with the blocker's placeholder, never a second + // tree: the DOM survives the block being raised and cleared. + expect(box.disabled).toBe(true) + expect(box.placeholder).toBe('select a model first') + fireEvent.keyDown(box, { key: 'Enter' }) + expect(b.sink).not.toHaveBeenCalled() + }) + + it('lets the no-workspace posture win over a block', () => { + // Picking a workspace is the earlier prerequisite; naming a model first + // would send the user somewhere they cannot act yet. + const b = mount(conversationSnapshot({ composerPhase: 'blank' }), [], undefined, { + summaryBlank: true, + composerBlock: { reason: 'select a model first' }, + }) + const box = b.view.getByRole('textbox') as HTMLTextAreaElement + expect(box.disabled).toBe(true) + expect(box.placeholder).not.toBe('select a model first') + }) + it('keeps composer text in the machine, mirrors to the chat store, and submits through the sink', () => { const b = mount(conversationSnapshot()) const box = b.view.getByRole('textbox') diff --git a/packages/client/ui-model/README.i18n.yaml b/packages/client/ui-model/README.i18n.yaml index 69ec84f418..25020ab686 100644 --- a/packages/client/ui-model/README.i18n.yaml +++ b/packages/client/ui-model/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-model/README.md -README.md: 5f9fc65939eb747d916fa5609423d3186d1fefde -README.zh.md: 3ed8db3095d96e48813cf5b15a206ebf4c894950 +README.md: 5a6f998476629566d35af32efa5d8bc5072a872b +README.zh.md: 2bb22c55f1ae5af59f21e254804329d288806a90 diff --git a/packages/client/ui-model/README.md b/packages/client/ui-model/README.md index 5f9fc65939..5a6f998476 100644 --- a/packages/client/ui-model/README.md +++ b/packages/client/ui-model/README.md @@ -6,6 +6,8 @@ Model selection plugin, browser half: TWO entries over ONE per-session directory The Host-reported provider/model/reasoning target is the single selection fact, but it is echoed only when the exact route remains in the advertised groups; removing that catalog row leaves the routable target intact while the trigger prompts `Select model`, no stale row is synthesized, and no Effort row is shown until the user picks an advertised model. Directory loads and selections share a generation counter so an older response never overwrites a newer one; a connection reset drops every resident projection and repulls the Host-restored target before display. Provider-local metadata failures list inline while usable groups stay selectable, and selection failures retain the prior target and directory. +When the Host reports that no adapter serves the session's route (`session.models.routable`), this plugin raises a composer block through `ctx.conversation.blocks` and the input goes inert with this plugin's own copy; recovering clears it without a reload. It follows `routable` and nothing else: a `null` — before the first load, or after one failed — never blocks, or a slow Host would lock a working composer, and catalog membership never blocks either, because a route serving a model it stopped advertising is missing from the groups yet perfectly usable. The trigger's own `Select model` fallback still covers that case, which is display, not a gate. + Directories are per-session, resolved lazily through `ctx.models.directoryFor(sessionId)`, and disposed with the session scope. Addressed subagent sessions expose neither entry, and their directory rejects loads, selections, and reconnect refreshes, because ordinary Agent-bound model RPCs would activate persisted child history outside the direct-parent continuation seam. The `/client` export surface is the plugin body (`apply`/`inject`), `ModelService`, `ModelDirectory` with its state shape, and the seat's injected face type. diff --git a/packages/client/ui-model/README.zh.md b/packages/client/ui-model/README.zh.md index 3ed8db3095..2bb22c55f1 100644 --- a/packages/client/ui-model/README.zh.md +++ b/packages/client/ui-model/README.zh.md @@ -6,6 +6,8 @@ Host 报告的提供方/模型/推理(reasoning)目标是唯一的选择事实,但只有当该精确路由仍在已公布分组中时才会回显;删除该目录行会保留仍可路由的目标,但触发器会提示 `Select model`,系统不会合成陈旧行,且在用户选择已公布的模型之前不会显示 Effort 行。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置会丢弃所有常驻目录投影,并在显示前重新拉取 Host 恢复的目标。各提供方的元数据获取失败会内联列出,同时可用分组仍可选择;选择失败会保留先前的目标和目录。 +当宿主报告没有适配器服务该会话的路由(`session.models.routable`)时,本插件经 `ctx.conversation.blocks` 抬起一个编辑器 block,输入框随之变为惰性并显示本插件自己的文案;恢复后无需重新加载即自动清除。它只跟随 `routable`:`null`(首次加载之前,或加载失败之后)绝不阻断,否则一个慢的宿主就会锁死一个本来能用的编辑器;目录成员关系同样不阻断,因为一条仍在服务、只是不再公布该模型的路由不在分组里,却完全可用。触发器自己的 `Select model` 回退仍然覆盖那种情形——那是显示,不是闸门。 + 目录按会话惰性解析(`ctx.models.directoryFor(sessionId)`),随会话作用域一并释放。已寻址 subagent 会话不公开任一入口,其目录会拒绝加载、选择与重新连接刷新,因为绑定到 agent(智能体)的普通模型 RPC 会在直接 parent 继续执行 seam 之外激活持久化 child 历史。 `/client` 导出面为插件本体(`apply`/`inject`)、`ModelService`、`ModelDirectory` 及其状态形状、slot 注入面类型。 diff --git a/packages/client/ui-model/src/client/directory.ts b/packages/client/ui-model/src/client/directory.ts index c98549bae8..8432cf1b77 100644 --- a/packages/client/ui-model/src/client/directory.ts +++ b/packages/client/ui-model/src/client/directory.ts @@ -15,6 +15,14 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' export interface ModelDirectoryState { /** Target the host reports for the next assembled step; null before the first load. */ current: ModelTarget | null + /** + * Whether an adapter serves the current target's route, as the host reports + * it — null before the first load, which is NOT the same as blocked. Read + * this rather than "current matches no group": catalog membership is + * advisory, so a route serving a model it stopped advertising is missing + * from the groups yet perfectly usable. + */ + routable: boolean | null /** Successfully loaded provider groups (last good load). */ groups: readonly ModelProviderGroup[] /** Provider-local failures from the last load; usable groups stay usable. */ @@ -29,7 +37,7 @@ export interface ModelDirectoryState { export class ModelDirectory { /** The shared snapshot both entries render from (uSES-safe store). */ readonly store: SnapshotStore<ModelDirectoryState> = createSnapshotStore<ModelDirectoryState>({ - current: null, groups: [], failures: [], status: 'idle', error: null, + current: null, routable: null, groups: [], failures: [], status: 'idle', error: null, }) /** Latest operation wins; an older response never overwrites a newer one. */ @@ -65,9 +73,10 @@ export class ModelDirectory { this.store.update((s) => { s.status = 'error'; s.error = `${result.error.code}: ${result.error.message}` }) throw new Error(`session.models failed: ${result.error.code}: ${result.error.message}`) } - const { current, groups, failures } = result.value + const { current, routable, groups, failures } = result.value this.store.update((s) => { s.current = current + s.routable = routable s.groups = groups s.failures = failures s.status = 'ready' @@ -102,7 +111,14 @@ export class ModelDirectory { this.store.update((s) => { s.status = 'error'; s.error = `${result.error.code}: ${result.error.message}` }) throw new Error(`session.selectModel failed: ${result.error.code}: ${result.error.message}`) } - this.store.update((s) => { s.current = result.value.selected; s.status = 'ready'; s.error = null }) + // The Host validated the route before accepting it, so a selection that + // landed is by construction one it can serve. + this.store.update((s) => { + s.current = result.value.selected + s.routable = true + s.status = 'ready' + s.error = null + }) } /** @@ -115,6 +131,7 @@ export class ModelDirectory { ++this.generation this.store.update((s) => { s.current = null + s.routable = null s.groups = [] s.failures = [] s.status = 'idle' diff --git a/packages/client/ui-model/src/client/index.ts b/packages/client/ui-model/src/client/index.ts index 0dde4778e1..68a82bd20a 100644 --- a/packages/client/ui-model/src/client/index.ts +++ b/packages/client/ui-model/src/client/index.ts @@ -105,14 +105,16 @@ export const inject = ['command', 'connection', 'locale', 'sessions', 'slots'] * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { - ctx.plugin(ModelService) - ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-model: dictionaries') // Non-slot faces (the command description, the popup option builder) read // through the bound translate; the seat component reads the standard seat. const t = ctx.locale.bind(NS) + // The composer-block reason is this plugin's own copy, read at raise time so + // a locale change reaches the next publish. + ctx.plugin(ModelService, { blockReason: () => t('blocked.composer') }) + // Entry 1: the /model popupSelect over the shared directory. The command // description is registry-held text: it reads t() once at registration and // refreshes only on re-registration, not on locale change. diff --git a/packages/client/ui-model/src/client/locales.ts b/packages/client/ui-model/src/client/locales.ts index f2b8bf1f01..2200eee56b 100644 --- a/packages/client/ui-model/src/client/locales.ts +++ b/packages/client/ui-model/src/client/locales.ts @@ -25,6 +25,7 @@ export const zh = { 'action.reload': '重新加载', 'warning.groupLoad': '{name} 加载失败:{message}', 'empty.models': '没有可用的模型。', + 'blocked.composer': '当前模型不可用,请先选择模型', 'empty.efforts': '当前模型未提供推理等级。', } satisfies Record<string, string> @@ -48,5 +49,6 @@ export const en = { 'action.reload': 'Reload', 'warning.groupLoad': '{name} failed to load: {message}', 'empty.models': 'No models available.', + 'blocked.composer': 'This model is unavailable — select one to continue', 'empty.efforts': 'This model provides no reasoning effort levels.', } satisfies Record<ModelKey, string> diff --git a/packages/client/ui-model/src/client/service.ts b/packages/client/ui-model/src/client/service.ts index 5506bf13a5..c2a8741464 100644 --- a/packages/client/ui-model/src/client/service.ts +++ b/packages/client/ui-model/src/client/service.ts @@ -36,11 +36,16 @@ export class ModelService extends Service { private readonly live: LiveState = { directories: new Map() } + /** Localized composer-block copy; this plugin owns the string it raises. */ + private readonly blockReason: () => string + /** * @param ctx - owning root context (the service registers itself as `models`). + * @param config - the bound translator for this plugin's own dictionary. */ - constructor(ctx: Context) { + constructor(ctx: Context, config: { blockReason: () => string }) { super(ctx, 'models') + this.blockReason = config.blockReason ctx.on('connection/reset', () => { for (const directory of this.live.directories.values()) directory.resetConnected() }) @@ -74,6 +79,27 @@ export class ModelService extends Service { () => sessions.subagentAddress(sessionId) === undefined, ) live.directories.set(sessionId, directory) + // The composer cannot read this plugin (the dependency runs one way), so + // the block is pushed: the Host says whether an adapter serves the + // session's route, and only a definite `false` makes the input inert. + // `null` — before the first load, or after one failed — must not, or a + // slow or unreachable Host would lock a working composer. + const conversation = this.ctx.get('conversation') + if (conversation !== undefined) { + const publish = (): void => { + conversation.blocks.set(sessionId, directory.store.getSnapshot().routable === false + ? { reason: this.blockReason() } + : undefined) + } + publish() + actx.effect(() => { + const stop = directory.store.subscribe(publish) + return () => { + stop() + conversation.blocks.set(sessionId, undefined) + } + }, 'ui-model: composer block') + } actx.effect(() => () => { directory.dispose() live.directories.delete(sessionId) diff --git a/packages/client/ui-model/tests/browser-plugin.spec.ts b/packages/client/ui-model/tests/browser-plugin.spec.ts index 5f3ac64add..4fbeca5600 100644 --- a/packages/client/ui-model/tests/browser-plugin.spec.ts +++ b/packages/client/ui-model/tests/browser-plugin.spec.ts @@ -17,6 +17,7 @@ import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client' import type { CommandContribution, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client' import type { ModelSelectInjected } from '../src/client/slots.ts' import { apply, inject } from '../src/client/index.ts' +import { zh } from '../src/client/locales.ts' const sid = (k: string): SessionId => k as SessionId @@ -59,7 +60,9 @@ async function bench() { ctx.provide('connection', { api: { sessions: { models: () => { calls.models += 1 - return Promise.resolve({ result: { ok: true as const, value: { current, groups: GROUPS, failures: [] } } }) + return Promise.resolve({ + result: { ok: true as const, value: { current, routable, groups: GROUPS, failures: [] } }, + }) }, selectModel: (payload: { provider: string; model: string; reasoningEffort?: string }) => { calls.select += 1 @@ -73,6 +76,15 @@ async function bench() { return Promise.resolve({ result: { ok: true as const, value: { selected: current } } }) }, } } }) + // Whether the Host reports an adapter for the current route; the composer + // block follows this, never catalog membership. + let routable = true + const blocks = new Map<SessionId, { reason: string } | undefined>() + ctx.provide('conversation', { + blocks: { + set: (id: SessionId, block: { reason: string } | undefined) => { blocks.set(id, block) }, + }, + }) let contribution: CommandContribution | undefined ctx.provide('command', { register(c: CommandContribution) { @@ -115,6 +127,8 @@ async function bench() { hostCurrent: () => current, setHostCurrent: (target: ModelTarget) => { current = target }, address: (id: SessionId) => { addressed.add(id) }, + setRoutable: (next: boolean) => { routable = next }, + blockOf: (key: string) => blocks.get(sid(key)), } } @@ -217,6 +231,63 @@ describe('ui-model dual entry', () => { expect(face2.directory).not.toBe(face1.directory) }) + it('blocks the composer only once the Host reports the route unservable', async () => { + const b = await bench() + b.mint('s1') + const face = b.seat().inject!(sid('s1')) + + // Before the first load nothing is known. `null` is not `false`: a slow + // or unreachable Host must never lock a working composer. + expect(b.blockOf('s1')).toBeUndefined() + face.load() + await Promise.resolve() + await Promise.resolve() + expect(b.blockOf('s1')).toBeUndefined() + + b.setRoutable(false) + b.ctx.emit('models/changed') + await Promise.resolve() + await Promise.resolve() + expect(b.blockOf('s1')?.reason).toBe(zh['blocked.composer']) + + // Recovering clears it without a reload of the surface. + b.setRoutable(true) + b.ctx.emit('models/changed') + await Promise.resolve() + await Promise.resolve() + expect(b.blockOf('s1')).toBeUndefined() + }) + + it('never blocks on catalog membership alone', async () => { + const b = await bench() + b.mint('s1') + const face = b.seat().inject!(sid('s1')) + // A model the route serves but no longer advertises: the seat prompts for + // a selection, the composer stays usable. Blocking here would break a + // supported configuration (a narrowed `models` list over a live route). + b.setHostCurrent({ provider: 'deepseek-official', model: 'unlisted' }) + face.load() + await Promise.resolve() + await Promise.resolve() + const snapshot = face.directory.getSnapshot() + expect(snapshot.groups.flatMap(group => group.models.map(model => model.id))).not.toContain('unlisted') + expect(b.blockOf('s1')).toBeUndefined() + }) + + it('clears its block when the session scope goes', async () => { + const b = await bench() + const scope = b.mint('s1') + b.setRoutable(false) + const face = b.seat().inject!(sid('s1')) + face.load() + await Promise.resolve() + await Promise.resolve() + expect(b.blockOf('s1')).toBeDefined() + + await scope.fiber.dispose() + expect(b.blockOf('s1')).toBeUndefined() + }) + it('an unknown session fails loud at the seat inject', async () => { const b = await bench() expect(() => b.seat().inject!(sid('ghost'))).toThrow(/resolved no scope/) diff --git a/packages/client/ui-model/tests/model-select.spec.tsx b/packages/client/ui-model/tests/model-select.spec.tsx index 45df8ab38e..75a0622ed6 100644 --- a/packages/client/ui-model/tests/model-select.spec.tsx +++ b/packages/client/ui-model/tests/model-select.spec.tsx @@ -32,6 +32,7 @@ const reasoning = { function state(overrides: Partial<ModelDirectoryState> = {}): ModelDirectoryState { return { current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + routable: true, groups: [{ id: 'deepseek-official', name: 'DeepSeek', diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index 01bf1be683..21e32891eb 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: fdd27478be25d4352462c2db0fc7eead1d1aee77 -README.zh.md: c0fd66b003593e7a535e28751f1f6d0ee80dfac1 +README.md: fe8e9851978ebd900fa43eb28f52a5a803501b63 +README.zh.md: c11ec125c6bf896c32db74161a263ee2b73c88be diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index fdd27478be..fe8e985197 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.<route>`, and the key travels separately through `credentials.set` under the same `<ROUTE>_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. 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.<route>`, and the key travels separately through `credentials.set` under the same `<ROUTE>_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. ## Model Experience diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index c0fd66b003..c11ec125c6 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.<route>` 上设置整个 profile,密钥则经 `credentials.set` 单独传递,使用与既有提供方相同的 `<ROUTE>_API_KEY` 派生。手工声明的路由无法默认的东西会门控创建按钮——唯一的 **Provider ID**、端点、协议,以及至少一个由唯一标识的模型——因此失败会在用户仍看着该字段时点名它。容量不参与门控:端点只按 id 描述的模型(这正是多数列表返回的形态)由适配器的回退值定尺寸。协议选项读自该 namespace 自己的 schema,而非某个协议字段或常量,因此它们不会与适配器实际接受的集合发生漂移。只有键入了密钥,这张卡片才记录约定的 `apiKeyEnv` 引用,与编辑器同一条规则,因此一条为提供方原生认证声明的路由不会一出生就指向一个永远不会被设置的引用。这张卡片提供与编辑器卡片在该 namespace 下相同的提供方级推理等级,两者共用同一个控件:它们写的是同一个 profile 字段,若声明时没有它,这个设置就会等到重新打开编辑时才凭空出现。 +**添加自定义提供方**用来声明 pi-ai 未提供的路由。它是独立的一张卡片而非在编辑器上加字段,因为路由 id 正是在这里被*选定*的,而在选定之前 settings 地址并不存在:一次 `settings.mutate` 在 `providers.<route>` 上设置整个 profile,密钥则经 `credentials.set` 单独传递,使用与既有提供方相同的 `<ROUTE>_API_KEY` 派生。手工声明的路由无法默认的东西会门控创建按钮——唯一的 **Provider ID**、端点、协议,以及至少一个由唯一标识的模型——因此失败会在用户仍看着该字段时点名它。容量不参与门控:端点只按 id 描述的模型(这正是多数列表返回的形态)由适配器的回退值定尺寸。协议选项读自该 namespace 自己的 schema,而非某个协议字段或常量,因此它们不会与适配器实际接受的集合发生漂移。只有键入了密钥,这张卡片才记录约定的 `apiKeyEnv` 引用,与编辑器同一条规则,因此一条为提供方原生认证声明的路由不会一出生就指向一个永远不会被设置的引用。当 profile 写入成功而密钥写入失败时,提供方其实已经存在:卡片会把描述它的字段定住,只重试凭据——再跑一次 profile 写入会带着刚被自己这次写入取代的 revision,宿主将以 `settings-conflict` 应答,密钥就再也无法从这里存下——并且即使用户随后取消,也照实报告提供方已创建。这张卡片提供与编辑器卡片在该 namespace 下相同的提供方级推理等级,两者共用同一个控件:它们写的是同一个 profile 字段,若声明时没有它,这个设置就会等到重新打开编辑时才凭空出现。 ## 模型体验 diff --git a/packages/client/ui-models/src/client/CustomProviderCard.tsx b/packages/client/ui-models/src/client/CustomProviderCard.tsx index ffdc57baf0..a83e2bb2fe 100644 --- a/packages/client/ui-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-models/src/client/CustomProviderCard.tsx @@ -74,7 +74,15 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { const [models, setModels] = useState<readonly ModelDraft[]>([]) const [busy, setBusy] = useState(false) const [failure, setFailure] = useState<string | undefined>(undefined) + /** + * The profile write landed. Only the key write can still be outstanding, so + * the fields that describe the provider are settled and the retry path is + * the credential alone. + */ + const [committed, setCommitted] = useState(false) const disabled = props.readOnly || busy + /** Everything but the key stops being editable once the provider exists. */ + const profileDisabled = disabled || committed const routeInvalid = route.length > 0 && !ROUTE_PATTERN.test(route) const routeTaken = taken.includes(route) @@ -98,34 +106,42 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { /** Perform the create, returning a failure message or undefined. */ const createOnce = async (): Promise<string | undefined> => { const keyRef = deriveKeyRef(route) - const storesKey = keyDraft.trim().length > 0 - const profile = { - ...displayName.length === 0 ? {} : { displayName }, - // The profile names the conventional reference only when this card is - // about to store a key, matching the editor: a route declared with the - // key left blank keeps its provider-native auth path (a credential - // chain, ADC) instead of resolving a reference nothing ever sets. - ...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 normalizedKey = keyDraft.trim() + const storesKey = normalizedKey.length > 0 + if (!committed) { + const profile = { + ...displayName.length === 0 ? {} : { displayName }, + // The profile names the conventional reference only when this card is + // about to store a key, matching the editor: a route declared with the + // key left blank keeps its provider-native auth path (a credential + // chain, ADC) instead of resolving a reference nothing ever sets. + ...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({ + ns: NS, + ops: [{ op: 'set', path: ['providers', route], value: profile }], + // `taken` is a snapshot too, so the id check alone cannot see a route + // declared after this card opened; the revision makes that race a + // `settings-conflict` instead of a write over the other profile. + expectedRevision: openedAt, + }) + if (!response.result.ok) return response.result.error.message + // The provider now exists. A retry after the key write below fails must + // not re-run this mutate: the revision it holds is the one this write + // just superseded, so the Host would answer `settings-conflict` and the + // key could never be stored from this card at all. + setCommitted(true) } - const response = await api.settings.mutate({ - ns: NS, - ops: [{ op: 'set', path: ['providers', route], value: profile }], - // `taken` is a snapshot too, so the id check alone cannot see a route - // declared after this card opened; the revision makes that race a - // `settings-conflict` instead of a write over the other profile. - expectedRevision: openedAt, - }) - if (!response.result.ok) return response.result.error.message if (storesKey) { - const stored = await api.credentials.set({ ref: keyRef, value: keyDraft }) + const stored = await api.credentials.set({ ref: keyRef, value: normalizedKey }) // The profile landed; saying the key did not is the only honest report, - // and the row is now editable so the key can be entered again there. + // and the retry above now goes straight back to this write. if (!stored.result.ok) return stored.result.error.message } return undefined @@ -163,7 +179,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { value={route} placeholder="acme-gateway" aria-label={t('customRoute')} - disabled={disabled} + disabled={profileDisabled} onChange={(event) => { setRoute(event.target.value) }} /> </div> @@ -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) }} /> </div> @@ -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) }} /> </div> @@ -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 => <option key={choice} value={choice}>{choice}</option>)} @@ -226,7 +242,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { value={effort ?? ''} onChange={setEffort} t={t} - disabled={disabled} + disabled={profileDisabled} /> <ModelListEditor models={models} @@ -239,7 +255,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { }} api={api} t={t} - disabled={disabled} + disabled={profileDisabled} /> {failure !== undefined ? <p className={styles['error']}>{failure}</p> : 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() }} /> </div> 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<Parameters<typeof CustomProviderCard>[0]> = {}) { - const scripted = scriptedFace() + function mountCard( + overrides: Partial<Parameters<typeof CustomProviderCard>[0]> = {}, + wire: Parameters<typeof scriptedFace>[0] = {}, + ) { + const scripted = scriptedFace(wire) const onClose = vi.fn() render( <CustomProviderCard @@ -736,6 +739,60 @@ describe('hand-declared providers', () => { 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<HTMLInputElement>(en.customRoute).disabled).toBe(true) + expect(screen.getByLabelText<HTMLInputElement>(en.baseUrl).disabled).toBe(true) + expect(screen.getByLabelText<HTMLInputElement>(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<void> @@ -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<Wire<ResponseValue<'session.models'>>> 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<Config>): z<DefaultRouteSettings> { - const fields = Object.fromEntries( - DEFAULT_ROUTE_FIELDS.map(field => [field, config.dict?.[field]]), - ) - return z.object(fields) as z<DefaultRouteSettings> -} +export const DEFAULT_ROUTE_SCHEMA: z<DefaultRouteSettings> = 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<Config> = 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<string, unknown> = {} + + get writable(): boolean { + return true + } + + protected load(): Promise<Record<string, unknown>> { + return Promise.resolve(structuredClone(this.doc)) + } + + protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> { + 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 <jyc132780208@sina.com> Date: Fri, 7 Aug 2026 16:15:10 +0800 Subject: [PATCH 59/67] 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 <StateDot state="error" /> case 'stopped': return <StateDot state="warning" /> - default: return <IconSkillOutline16 /> + default: return <IconSkillOutline16 size={14} /> } } 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 <jyc132780208@sina.com> Date: Fri, 7 Aug 2026 16:45:50 +0800 Subject: [PATCH 60/67] 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: <active id>`), 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: <active id>`);视图标签页则从注册选项(`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({ </div> <div className={css.trailing}> {rightItems} - {renderSlot('conversation.input.model', { locked })} + {renderSlot('conversation.input.model', { locked: modelSeatLocked })} <ContextMeter useProjection={useProjection} t={t} /> {/* {machineBusy && <span className={css.pending} data-input-pending aria-label="处理中" />} */} <Tooltip label={primaryLabel} side="top" delayMs={500}> 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(<ConversationRoot {...props} />) return { - view, chat, sink, retargetWorkspace, session, slotCalls, open, + view, chat, sink, retargetWorkspace, session, slotCalls, seatOwners, open, pickerOwner: () => pickerOwner, rerender: () => { view.rerender(<ConversationRoot {...props} />) }, } @@ -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.<route>`, and the key travels separately through `credentials.set` under the same `<ROUTE>_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.<route>`, and the key travels separately through `credentials.set` under the same `<ROUTE>_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.<route>` 上设置整个 profile,密钥则经 `credentials.set` 单独传递,使用与既有提供方相同的 `<ROUTE>_API_KEY` 派生。手工声明的路由无法默认的东西会门控创建按钮——唯一的 **Provider ID**、端点、协议,以及至少一个由唯一标识的模型——因此失败会在用户仍看着该字段时点名它。容量不参与门控:端点只按 id 描述的模型(这正是多数列表返回的形态)由适配器的回退值定尺寸。协议选项读自该 namespace 自己的 schema,而非某个协议字段或常量,因此它们不会与适配器实际接受的集合发生漂移。只有键入了密钥,这张卡片才记录约定的 `apiKeyEnv` 引用,与编辑器同一条规则,因此一条为提供方原生认证声明的路由不会一出生就指向一个永远不会被设置的引用。当 profile 写入成功而密钥写入失败时,提供方其实已经存在:卡片会把描述它的字段定住,只重试凭据——再跑一次 profile 写入会带着刚被自己这次写入取代的 revision,宿主将以 `settings-conflict` 应答,密钥就再也无法从这里存下——并且即使用户随后取消,也照实报告提供方已创建。这张卡片提供与编辑器卡片在该 namespace 下相同的提供方级推理等级,两者共用同一个控件:它们写的是同一个 profile 字段,若声明时没有它,这个设置就会等到重新打开编辑时才凭空出现。 +**添加自定义提供方**用来声明 pi-ai 未提供的路由。它是独立的一张卡片而非在编辑器上加字段,因为路由 id 正是在这里被*选定*的,而在选定之前 settings 地址并不存在:一次 `settings.mutate` 在 `providers.<route>` 上设置整个 profile,密钥则经 `credentials.set` 单独传递,使用与既有提供方相同的 `<ROUTE>_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<string | undefined>(undefined) const [models, setModels] = useState<readonly ModelDraft[]>([]) const [busy, setBusy] = useState(false) const [failure, setFailure] = useState<string | undefined>(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 : <p className={styles['error']}>{t(keyFailure === 'keyBlank' ? 'keyBlankNew' : keyFailure)}</p>} </div> - {/* The same control the editor card shows for this namespace: a route - declared here and edited there must offer the same profile. */} - <ReasoningEffortField - family="pi-ai" - value={effort ?? ''} - onChange={setEffort} - t={t} - disabled={profileDisabled} - /> <ModelListEditor models={models} onChange={setModels} diff --git a/packages/client/ui-models/src/client/ModelsSection.tsx b/packages/client/ui-models/src/client/ModelsSection.tsx index 9cbfba9a5a..0d1060978d 100644 --- a/packages/client/ui-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-models/src/client/ModelsSection.tsx @@ -54,6 +54,8 @@ interface EditorTarget extends ProviderIdentity { settingsPath: readonly string[] /** Writable credential identified under this page's conventional reference. */ credentialRef?: string + /** Directory passthrough: the owning adapter ships nothing under this route. */ + declared?: boolean } /** Values that vary around the shared provider-editor rendering. */ @@ -71,6 +73,7 @@ function renderProviderEditor({ target, ...props }: ProviderEditorRenderProps): provider={target.provider} displayName={target.displayName} settingsPath={target.settingsPath} + {...target.declared === undefined ? {} : { declared: target.declared }} {...props} /> ) @@ -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<IApiClient, 'settings' | 'credentials' | 'llm'> /** 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 { }} /> </div> - <ReasoningEffortField - family={family} - value={stringAt(draft, effortField) ?? ''} - onChange={(effort) => { 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 : ( + <ReasoningEffortField + family={family} + value={stringAt(draft, effortField) ?? ''} + onChange={(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 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<HTMLSelectElement>(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 <jyc132780208@sina.com> Date: Fri, 7 Aug 2026 17:02:05 +0800 Subject: [PATCH 61/67] 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 62/67] 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<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 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<PropsRenderSlots<'conversation.chat.turnTail'>, '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 && <span className={css.stopped}>{t('message.stopped')}</span>} </div> - {showActions && tail} + {showActions && turnTail?.renderSlotChain('conversation.chat.turnTail', turnTail.owner)} {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 ee43ff080d..b0907f5a80 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -335,7 +335,7 @@ function StreamingTail({ useSession, t }: { * render through the declared keyed hole's renderSlot share). */ export function ChatView({ - useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, t, + useSession, useSessions, useStore, renderSlot, renderSlotChain, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, t, }: ChatViewSlotProps) { const nodes = useSession(s => 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<ConversationSnapshot>) { 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<ConversationSnapshot>) { 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 <div data-testid="counting-row" /> 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<typeof NS> +/** Matched paths plus the opener and locale seats needed to present them. */ +export type ProducedFilesProps = Pick<TurnTailOwnerProps, 'openFile'> & { + matched: readonly string[] +} & 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. + * @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(<ProducedFiles nodes={nodes} seq={3} openFile={openFile} t={t} />) + const view = render(<ProducedFiles matched={paths} 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' }) @@ -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(<ProducedFiles nodes={nodes} seq={2} openFile={() => {}} 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 63/67] 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 <jyc132780208@sina.com> Date: Fri, 7 Aug 2026 17:36:08 +0800 Subject: [PATCH 64/67] 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 `<ROUTE>_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 `<ROUTE>_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.<route>`, and the key travels separately through `credentials.set` under the same `<ROUTE>_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.<route>`, and the key travels separately through `credentials.set` under the same `<ROUTE>_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 没有引用时便派生 `<ROUTE>_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 没有引用时便派生 `<ROUTE>_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.<route>` 上设置整个 profile,密钥则经 `credentials.set` 单独传递,使用与既有提供方相同的 `<ROUTE>_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.<route>` 上设置整个 profile,密钥则经 `credentials.set` 单独传递,使用与既有提供方相同的 `<ROUTE>_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) }} /> </div> - <p className={styles['advancedHint']}> - {routeInvalid ? t('customRouteInvalid') : routeTaken ? t('customRouteTaken') : t('customRouteHint')} - </p> + {/* 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 + ? <p className={styles['error']}>{t(routeInvalid ? 'customRouteInvalid' : 'customRouteTaken')}</p> + : <p className={styles['advancedHint']}>{t('customRouteHint')}</p>} <div className={styles['field']}> <span className={styles['fieldLabel']}>{t('customDisplayName')}</span> <input diff --git a/packages/client/ui-models/src/client/ModelsSection.tsx b/packages/client/ui-models/src/client/ModelsSection.tsx index 0d1060978d..9cbfba9a5a 100644 --- a/packages/client/ui-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-models/src/client/ModelsSection.tsx @@ -54,8 +54,6 @@ interface EditorTarget extends ProviderIdentity { settingsPath: readonly string[] /** Writable credential identified under this page's conventional reference. */ credentialRef?: string - /** Directory passthrough: the owning adapter ships nothing under this route. */ - declared?: boolean } /** Values that vary around the shared provider-editor rendering. */ @@ -73,7 +71,6 @@ function renderProviderEditor({ target, ...props }: ProviderEditorRenderProps): provider={target.provider} displayName={target.displayName} settingsPath={target.settingsPath} - {...target.declared === undefined ? {} : { declared: target.declared }} {...props} /> ) @@ -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<IApiClient, 'settings' | 'credentials' | 'llm'> /** 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 { }} /> </div> - {/* 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 : ( - <ReasoningEffortField - family={family} - value={stringAt(draft, effortField) ?? ''} - onChange={(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 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<EffortFamily, readonly string[]> = { - 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<EffortFamily, string> = { - 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 ( - <div className={styles['field']}> - <span className={styles['fieldLabel']}>{t('effort')}</span> - <select - className={`${styles['input']} ${styles['selectInput']}`} - value={value} - aria-label={t('effort')} - disabled={disabled} - onChange={(event) => { onChange(event.target.value === '' ? undefined : event.target.value) }} - > - <option value="">{t('effortInherit')}</option> - {EFFORT_CHOICES[family].map(choice => ( - <option key={choice} value={choice}>{choice}</option> - ))} - </select> - </div> - ) -} 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<HTMLSelectElement>(en.effort) - expect(effort.value).toBe('high') - fireEvent.change(effort, { target: { value: '' } }) + const url = screen.getByLabelText<HTMLInputElement>(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<HTMLInputElement>(en.baseUrl) expect(urls).toHaveLength(2) expect((urls[1] as HTMLInputElement).value).toBe('https://proxy') - const effort = screen.getAllByLabelText<HTMLSelectElement>(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<HTMLSelectElement>(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 <jyc132780208@sina.com> Date: Fri, 7 Aug 2026 18:07:15 +0800 Subject: [PATCH 65/67] 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<Api>, + 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<Api>, @@ -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 <jyc132780208@sina.com> Date: Fri, 7 Aug 2026 18:54:50 +0800 Subject: [PATCH 66/67] 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 67/67] 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 {