Merge origin/master into worktree/web-plugin-config

Master gave the agent-preset nav its own glyph; this branch's plugins glyph
sits beside it, and both keep their own icon. Master's stricter
no-base-to-string rule also reaches this branch's new provider test, whose
fetch-call assertion now names the types it reads instead of stringifying an
unknown body.
This commit is contained in:
Yichen Jiang
2026-08-11 11:38:46 +08:00
154 changed files with 2000 additions and 485 deletions
+6
View File
@@ -304,6 +304,12 @@
"process/2026-07-27-wine-windows-gates-experiment.i18n.yaml": "sha256:6f4cbc12ee9cddbb297bf7e138ccabcd204f66898a0f7411b1633f03d5a9eab5",
"process/2026-07-27-wine-windows-gates-experiment.md": "sha256:8d37dcdab058098c7de3da1de00ce61bef92bbc8d6ee71add959474c6fb3e936",
"process/2026-07-27-wine-windows-gates-experiment.zh.md": "sha256:77fbf04df36af09e55007a93bd6b22d08ff99869efe8de3e97dac5b4701e0a9e",
"process/2026-08-04-forward-only-pr-issue-status.i18n.yaml": "sha256:af23e203a66a95674154899410e2f420d1d0685dbf856c24cfccdaa547a17925",
"process/2026-08-04-forward-only-pr-issue-status.md": "sha256:2d31077da47d95ab3ddf64d5efc6b1b8fb7c7709d39aca4a825ef9e9d382d501",
"process/2026-08-04-forward-only-pr-issue-status.zh.md": "sha256:b61f865b7a8a0ac901250a3edbb92ea73177067c4c25448c7088925c2caeccd7",
"process/2026-08-08-review-driven-issue-lifecycle-triggers.i18n.yaml": "sha256:4c28c59d3fc323e7cd01eff31f1fe759834719c5bede1e82b39f868970bf856d",
"process/2026-08-08-review-driven-issue-lifecycle-triggers.md": "sha256:1b0514de5d030170e91e12e4d6ba788a9247f840e82700faa385a1c0c76ab857",
"process/2026-08-08-review-driven-issue-lifecycle-triggers.zh.md": "sha256:028d78d61f603d8bac64c4cce20b393a78f8e029d3bb4976e79a47ecaefa6032",
"simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.i18n.yaml": "sha256:ad3d1263cb0051b885173bf064de62065e2c646ccaae2d7250723da3b4eab90c",
"simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md": "sha256:8fb061d51c8c23b47d2367814bab3623c6d5b972f38d207a273caa9030b579bd",
"simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md": "sha256:2ffeaca91f82844a5616d6dcce6b4af514bb8a7c46f78e47f668b204ac6edc04",
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.md
2026-08-04-forward-only-pr-issue-status.md: dd567707bc7fccd0a631943ab3ffd2838a7f2f76
2026-08-04-forward-only-pr-issue-status.zh.md: f7fee58d6afb812f97569ae4d86c3d6504f35752
2026-08-04-forward-only-pr-issue-status.md: 56004a39ce52c77429574f481d9945cdc4936d30
2026-08-04-forward-only-pr-issue-status.zh.md: ee85319842d3245bdfab9668de0a42ab29597fac
@@ -1,6 +1,7 @@
# Agent Note: Forward-only PR-to-Issue status projection
Status: implemented
Archived: 2026-08-10
English | [中文](2026-08-04-forward-only-pr-issue-status.zh.md)
@@ -1,6 +1,7 @@
# Agent Note: PR 到 Issue 的状态仅向前投射
Status: implemented
Archived: 2026-08-10
[English](2026-08-04-forward-only-pr-issue-status.md) | 中文
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.md
2026-08-08-review-driven-issue-lifecycle-triggers.md: 8a2d48ee23da4c20bb832ae0109e2ea9912dac83
2026-08-08-review-driven-issue-lifecycle-triggers.zh.md: 004739ff471815b0fe12e111eba0ec7aaaef9507
2026-08-08-review-driven-issue-lifecycle-triggers.md: 444927968912d93f473e27ae8576e8371b9c287c
2026-08-08-review-driven-issue-lifecycle-triggers.zh.md: 6e00e2a936b6421824743e779756011fcd4a1c9e
@@ -1,6 +1,7 @@
# Agent Note: Review-driven Issue lifecycle triggers
Status: implemented
Archived: 2026-08-10
English | [中文](2026-08-08-review-driven-issue-lifecycle-triggers.zh.md)
@@ -1,6 +1,7 @@
# Agent Note: 由评审驱动的 Issue 生命周期触发器
Status: implemented
Archived: 2026-08-10
[English](2026-08-08-review-driven-issue-lifecycle-triggers.md) | 中文
@@ -1,33 +0,0 @@
# Agent Note: Experimental and internal package group
Status: implemented
English | [中文](2026-07-28-experimental-plugin-package-group.zh.md)
## Problem
The [package hierarchy](../../../../packages/README.md) groups plugins by product role, but it cannot distinguish release packages from prototypes or internal-only packages. The team needs an obvious shared place for useful work that is not part of the official release.
## Decision
The subtree rules in [`packages/experimental/AGENTS.md`](../../../../packages/experimental/AGENTS.md) make `packages/experimental/<pkg>/` the required home for Cordis plugin packages whose whole public contract is experimental or internal-only. Package names remain `@deepseek-ai/dsh-<pkg>`.
The group is the team's in-repository place to share engineering and product-manager prototypes: members can discover, run, review, and extend one another's work against the real plugin graph without implying product support.
Official releases exclude this directory. A package enters a release only after moving to its product-role group; release packages cannot take runtime dependencies on packages here. Examples may use them, while any other runtime dependent also belongs here. Tests may use them as development dependencies.
Experimental packages carry no stability, compatibility, migration, or support promise: they may change APIs, configuration, or data, or disappear without deprecation or migration. Internal-only packages may define narrower internal contracts but make no public release promise. Neither status relaxes engineering, security, documentation, lifecycle, testing, or snapshot requirements.
The pending `@deepseek-ai/dsh-tui-session-changes` `/diff` viewer and `/btw` plugin are examples governed by this rule. Promotion into an official release requires explicit review of the public contract, limitations, test evidence, and a named owner accepting stable-package obligations.
## Alternatives considered
**Keep experimental and internal-only packages in product-role groups with README labels.** Labels are easy to miss and cannot enforce dependency boundaries.
**Treat every package as experimental until the first tagged release.** This provides no durable incubation boundary.
**Develop prototypes and internal packages elsewhere.** This loses the real plugin graph, examples, snapshots, and lifecycle checks needed to evaluate them.
## Consequences
The path makes release exclusion and dependency blast radius visible while retaining the real plugin graph for team sharing. It gives up product-role colocation and creates path churn on promotion, while the npm name remains stable. The subtree rules, repository [current-owner/current-need rule](../../../../packages/AGENTS.md), and unchanged engineering gates limit junk-drawer growth. Because official release tooling does not yet exist, contributor policy enforces the exclusion; when such tooling is added, the directory is its required exclusion boundary.
@@ -1,33 +0,0 @@
# Agent Note: 实验性与内部专用包分组
Status: implemented
[English](2026-07-28-experimental-plugin-package-group.md) | 中文
## 问题
[包层级结构](../../../../packages/README.md)按产品角色对插件分组,但无法区分发布包、原型和内部专用包。团队需要一个明确的共享位置,存放不属于官方发布版本的有价值成果。
## 决策
[`packages/experimental/AGENTS.md`](../../../../packages/experimental/AGENTS.md) 中的子树规则要求所有公开约定整体处于实验状态或仅限内部使用的 Cordis 插件包位于 `packages/experimental/<pkg>/`。包名仍为 `@deepseek-ai/dsh-<pkg>`
该分组供团队在仓库内共享工程人员和产品经理制作的原型:成员可以基于真实插件图发现、运行、评审并扩展彼此的原型,但这不代表产品会提供支持。
官方发布版本不包含此目录。包只有移入对应的产品角色分组后才会纳入发布版本;发布包不得在运行时依赖此处的包。示例可以使用这些包;其他任何运行时依赖方也必须位于此处。测试可以将它们用作开发依赖。
实验性包不提供稳定性、兼容性、迁移或支持保证:其 API、配置或数据可以变更,包也可以移除,均不提供弃用期或迁移路径。内部专用包可以定义范围更窄的内部约定,但不作公开发布承诺。无论哪种状态,都不降低仓库对工程、安全、文档、生命周期、测试或快照的要求。
尚待完成的 `@deepseek-ai/dsh-tui-session-changes` `/diff` 查看器和 `/btw` 插件都受这项规则约束。将包提升为稳定包并纳入官方发布版本,需要明确评审其公开约定、限制和测试证据,并指定一名愿意承担稳定包义务的负责人。
## 考虑过的替代方案
**将实验性和内部专用包留在产品角色分组中,并用 README 标注。** 标注容易被忽略,也无法强制执行依赖边界。
**首个带标签的版本发布前,将所有包都视为实验性。** 这无法提供持久的孵化边界。
**在其他位置开发原型和内部专用包。** 这会失去评估它们所需的真实插件图、示例、快照和生命周期检查。
## 后果
该路径明确标示不纳入发布版本的包及其依赖影响范围,同时保留供团队共享成果的真实插件图。代价是这些包无法与同产品角色的包共置,提升并纳入发布版本时还会产生路径变动,但 npm 包名保持稳定。子树规则、仓库已有的[「必须有当前负责人和实际需求」规则](../../../../packages/AGENTS.md)以及保持不变的工程门禁,可限制该分组无序膨胀。由于官方发布工具尚不存在,目前由贡献者政策执行这项排除规则;添加发布工具后,必须以该目录为排除边界。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-29-package-regrouping.md
2026-07-29-package-regrouping.md: 3c37bce05bacd6af800a76ac93fb691b896a6772
2026-07-29-package-regrouping.zh.md: 68903ff1fad6a975c4445fe8971c8fe0dd40117f
2026-07-29-package-regrouping.md: 30fc45a122263350b4a2ad1998850f631c20f9b8
2026-07-29-package-regrouping.zh.md: a3a9a11ec71b7f894dcea7c733eb39a80b71ac50
@@ -58,7 +58,7 @@ The moves landed as pure `git mv` moves, so rename detection carries the history
A group move did not touch: npm names, imports, `cordis.yml` configs, snapshot fixtures, the `pnpm-workspace.yaml`/`tsdown` globs (both `packages/*/*`), or the Python runtime manifest — all reference packages by npm name.
`client/` and `host/` were out of scope and are unchanged. The `experimental/` group proposal (PR #844) is orthogonal — a release-boundary container, not a clustering decision.
`client/` and `host/` were out of scope and are unchanged.
## Alternatives considered
@@ -58,7 +58,7 @@ Status: implemented
组移动未触及:npm 包名、import、`cordis.yml` 配置、快照 fixture(测试前置数据)、`pnpm-workspace.yaml``tsdown` 的 glob(都是 `packages/*/*`),以及 Python 运行时 manifest(元数据清单)——它们全部按 npm 包名引用包。
`client/``host/` 不在本次范围内,保持不变。`experimental/` 组提案(PR #844)与本案正交:它是发布边界容器,不是聚类决策。
`client/``host/` 不在本次范围内,保持不变。
## Alternatives considered
@@ -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-10-subagent-empty-terminal-message-output.md
2026-08-10-subagent-empty-terminal-message-output.md: 693013f6810005ce02b08bd82f1f6a18511c40fb
2026-08-10-subagent-empty-terminal-message-output.zh.md: 64d61af21f838ef3f515db8af116cbdd74e96179
@@ -0,0 +1,31 @@
# Agent Note: One selection rule keeps subagent output past an empty terminal message
Status: implemented
English | [中文](2026-08-10-subagent-empty-terminal-message-output.zh.md)
## Problem
The agent loop appends an empty-content `assistant/message` when a `max-tokens` step assembled only tool-call blocks because `BlockAssembler.blocks()` drops truncated tool calls; the message records usage only. Three consumers selected the child's output independently and treated that usage record as output. The in-process driver's `readResult` and the continuable Activation's `subagent/end` capture selected the last `assistant/message` without filtering, while the SDK backend's observer let any `assistant/message` take precedence over accumulated text. In a multi-step turn cut off at max-tokens, the final empty message caused the real partial answer to be omitted from `SubagentResult.output`, the tool result, telemetry, and `subagent/end.lastAssistantMessage`. The in-process driver also lacked a streamed-text fallback, so a cancelled child whose only text existed in `assistant/chunk` events reported `[]`.
## Decision
`dsh-subagent` owns one canonical selection rule in `src/assistant-output.ts`: select the last non-empty assistant message; without one, select the accumulated `text-delta` stream; ignore empty-content messages. The incremental `AssistantOutputFold` implements the rule through `push(event)` for session-event transports, `pushText(text)` for chunk-only transports, and `collect()` for selection. `finalAssistantOutput(events)` applies it to a complete event suffix for the in-process `readResult` and Activation capture. The SDK backend folds notification events; the ACP backend exposes no complete assistant messages and folds raw chunk text. `SubagentResult.output` defines the result contract, and `subagent/end.lastAssistantMessage` uses the same rule. When a child produces neither form of output, the lifecycle field is absent rather than an empty array for both one-shot and continuable runs. A `max-tokens` or `aborted` result retains its actual stop reason.
The foreground delegation tool uses the same selection. A non-`completed` result remains an `isError` tool result, but its message appends the child's partial text after the stop-reason headline so the parent model receives both the failure and available output.
## Verification
The keyless SDK backend test uses `FAKE_EMPTY_MESSAGE` to emit a usage-only terminal message. The `subagent-max-tokens-partial` ACP snapshot records a child that streams text and a tool call, ends at a tool-only max-tokens step with an empty usage message in its durable log, and returns the partial text through the parent's errored tool result. Unit coverage checks empty terminal messages, cancellation, message ordering, textless non-empty messages, and exclusion of tool-result content.
## Alternatives considered
**Fix each consumer in place without a shared helper.** Rejected: three independent selections had diverged, while observers of one run must agree on its output.
**Stop the loop from appending the empty message.** Rejected: the message records usage and preserves the step in the durable log ("model-visible ⟺ logged"); changing session events to address output selection would affect every replay and projection consumer.
**Treat empty-content messages as an error.** Rejected: the streamed text is the child's real partial answer, and the stop reason already tells the consumer the turn was cut short.
## Consequences
Multi-step children cut off at max-tokens report their earlier text; cancelled in-process children retain text streamed before the abort; one-shot and continuable `subagent/end` events agree with `SubagentResult.output`. A message whose content is non-empty but textless, such as reasoning-only content, is selected instead of streamed text because the rule tests content length rather than text presence. A non-empty message is also selected instead of text streamed after it: a child cancelled while streaming a later step reports its earlier complete message, while the stop reason records the truncation.
@@ -0,0 +1,31 @@
# Agent Note: 用同一条选取规则在空终止消息后保留子代理输出
Status: implemented
[English](2026-08-10-subagent-empty-terminal-message-output.md) | 中文
## 问题
`max-tokens` 步骤只组装了工具调用块时,agent loop(智能体循环)会追加一条空内容的 `assistant/message`,因为 `BlockAssembler.blocks()` 会丢弃被截断的工具调用;这条消息仅记录 usage。三个消费方独立选取子 agent 的输出,并把这条 usage 记录当成输出。进程内驱动的 `readResult` 与 continuable Activation 的 `subagent/end` capture 不加过滤地选取最后一条 `assistant/message`SDK 后端的观察器则让任何 `assistant/message` 优先于累积的文本。在被 max-tokens 截断的多步轮次中,最后那条空消息导致 `SubagentResult.output`、工具结果、遥测与 `subagent/end.lastAssistantMessage` 都漏掉真实的部分回答。进程内驱动也没有流式文本兜底,因此被取消的子 agent 若其唯一文本只存在于 `assistant/chunk` 事件中,也会报告 `[]`
## 决策
`dsh-subagent``src/assistant-output.ts` 中拥有唯一的规范选取规则:选取最后一条非空 assistant 消息;没有时选取累积的 `text-delta` 流;忽略空内容消息。增量的 `AssistantOutputFold` 通过 `push(event)` 处理会话事件传输,通过 `pushText(text)` 处理仅分片传输,并通过 `collect()` 完成选取。`finalAssistantOutput(events)` 把规则应用于完整的事件后缀,供进程内 `readResult` 与 Activation capture 使用。SDK 后端折叠通知事件;ACP 后端不暴露完整的 assistant 消息,而是折叠原始分片文本。`SubagentResult.output` 定义结果约定,`subagent/end.lastAssistantMessage` 使用同一规则。子 agent 不产生这两种输出中的任何一种时,一次性与 continuable 运行的生命周期字段都会缺省,而不是空数组。`max-tokens``aborted` 结果保留实际的终止原因。
前台委派工具使用同一选取规则。非 `completed` 的结果仍是 `isError` 工具结果,但其消息会在终止原因标题之后附上子 agent 的部分文本,让父模型同时接收失败信息与已有输出。
## 验证
无密钥 SDK 后端测试使用 `FAKE_EMPTY_MESSAGE` 发出一条仅记录 usage 的终止消息。`subagent-max-tokens-partial` ACP 快照记录一个子 agent:它流式输出文本与一次工具调用,结束于仅含工具调用的 max-tokens 步骤,持久化日志中含一条空的 usage 消息,并通过父侧的错误工具结果返回部分文本。单元覆盖检查空终止消息、取消、消息顺序、不含文本的非空消息,以及排除工具结果内容。
## 考虑过的替代方案
**各消费方就地修复、不抽共享辅助函数。** 之所以否决:三处独立选取已发生分歧,而同一次运行的观察方必须对其输出达成一致。
**让 loop 不再追加空消息。** 之所以否决:这条消息记录 usage,并在持久化日志中保留该步骤("model-visible ⟺ logged");为处理输出选取而改动会话事件,会影响所有 replay 与 projection 消费方。
**把空内容消息视为错误。** 之所以否决:流式文本才是子代理真实的部分回答,且终止原因已经告诉消费方轮次被截断。
## 后果
被 max-tokens 截断的多步子 agent 会报告其更早的文本;被取消的进程内子 agent 保留中止前已流式的文本;一次性与 continuable 的 `subagent/end` 事件同 `SubagentResult.output` 一致。内容非空但不含文本的消息(例如仅含 reasoning 的内容)仍然优先于流式文本,因为规则检查内容长度,而不是文本是否存在。非空消息同样优先于其后才流式出的文本:子 agent 在流式输出后续步骤时被取消,报告的是更早那条完整消息,终止原因则记录该截断。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent 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: 3edb29283c289d6d006891a4c19087b01fa8166f
2026-07-28-feedback-command.zh.md: c2513d2570474cbbaf8d94f87603d8ce10d40c14
2026-07-28-feedback-command.md: d3b2774e41a82f6edb4303280f813ddbed75ebd1
2026-07-28-feedback-command.zh.md: 3eeef92f2ed39c9546f013f217dd7f851d30c78c
@@ -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 enter persistence's ordinary bounded write path; nothing forces a flush, so acknowledgement reports that the feedback is in the log rather than already on disk.
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).
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) and the [acknowledgement sharing disclosure](2026-08-07-feedback-acknowledgement-sharing-disclosure.md).
### Why feedback owns an event
@@ -18,7 +18,7 @@ Status: implemented
`dsh-commands` 仍会围绕 `/feedback` 写入 `command/run` / `command/done` 生命周期配对,但该命令设置了 `recordInput: false`。因此,它的 `command/run` 携带命令标识与来源,但不携带 `args`;反馈文本只存在于 `feedback/record` 中,而 `command/done` 携带确认结果。三个记录都仅写入日志且非 surface。它们的追加会进入持久化的常规有界写入路径;没有任何环节强制 flush,因此确认文本报告的是反馈已进入日志,而非已经落盘。
采集对正在运行的 agent(智能体)与模型仍不产生后续动作。可选的 OTel 遥测包后续增加了一个基础设施消费方:它在 `FEEDBACK_ONLY` 模式下将 `feedback/record` 作为释放触发器,在 `DISABLED` 模式下将其作为仅限本地的警告触发器,且不改变反馈事件或命令路径。见[反馈门控的会话遥测](2026-08-05-feedback-gated-session-telemetry.md)。
采集对正在运行的 agent(智能体)与模型仍不产生后续动作。可选的 OTel 遥测包后续增加了一个基础设施消费方:它在 `FEEDBACK_ONLY` 模式下将 `feedback/record` 作为释放触发器,在 `DISABLED` 模式下将其作为仅限本地的警告触发器,且不改变反馈事件或命令路径。见[反馈门控的会话遥测](2026-08-05-feedback-gated-session-telemetry.md)与[确认文本中的共享披露](2026-08-07-feedback-acknowledgement-sharing-disclosure.md)
### 为何反馈拥有自己的事件
@@ -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-feedback-acknowledgement-sharing-disclosure.md
2026-08-07-feedback-acknowledgement-sharing-disclosure.md: 1e9cd0fb95d78aff9f6434e0583154e2c3f847da
2026-08-07-feedback-acknowledgement-sharing-disclosure.zh.md: ac26b18ad523feeabc297b212210dd73eff93a0a
@@ -0,0 +1,27 @@
# Agent Note: Feedback acknowledgement sharing disclosure
Status: implemented
English | [中文](2026-08-07-feedback-acknowledgement-sharing-disclosure.zh.md)
## Problem
The `/feedback` command records a log-only `feedback/record` event and acknowledges the user, but the acknowledgement carried no durable context about what happened to the session: deployments that mount session telemetry (`FULL`, `FEEDBACK_ONLY`, or `DISABLED`) had no way to tell the user whether their feedback and session left the process, and the receiving session id was not echoed. The command plugin could not read the sharing policy because the telemetry seam exposed capture only, and the OTel mode enum lived in the optional backend package.
## Decision
The telemetry seam (`@deepseek-ai/dsh-session-telemetry`) now owns a backend-independent sharing vocabulary: `TelemetrySharingStatus` (`full` | `feedback-only` | `disabled`) plus a required abstract `sharing` member on the `Telemetry` service class — every backend must disclose its policy, so a consumer renders "not configured" only when no telemetry service is mounted. `@deepseek-ai/dsh-session-telemetry-otel` maps its serialized `TelemetryMode` (the [feedback-gated delivery decision](2026-08-05-feedback-gated-session-telemetry.md) owns the mode semantics) onto that status in the constructor and discloses it, including in `DISABLED`. The `/feedback` handler reads the mounted service through the plugin context (`ctx.get('telemetry')`, never a declared injection, so the command loads and runs without telemetry) and appends one sharing sentence to the acknowledgement: `Feedback recorded for session {id}. <sentence>`. No service → `Session sharing is not configured.`; `disabled``Session sharing is disabled.`; `feedback-only``Session sharing is feedback-gated; recording feedback releases the session prefix for sharing.`; `full``Session sharing is enabled.`
The disclosure states the current sharing policy only; it never promises delivery or retention. Handoff is the backend's non-blocking enqueue and batching, retry, and loss policy stay the backend SDK's, and a later reconfiguration can change what was shared, so the sentences claim nothing about what reached a collector or about future retention. The disclosure adds no session event and never reaches the model surface; the web client renders it through the existing command row (`CommandNode` outcome text) with no client change.
## Alternatives considered
**A client-side status RPC and badge.** Rejected because the acknowledgement is host-produced and the web client already renders the command result text verbatim in the command row; a separate RPC would duplicate the status in a second surface and add a wire contract for a sentence.
**Declared `telemetry` injection in `command-feedback`.** Rejected because telemetry is optional: a declared injection fails plugin load when the service is absent, while the command must work without it. The plugin reads the service with `ctx.get('telemetry')` at handler time instead.
**OTel package owns the vocabulary.** Rejected because `command-feedback` must not depend on the optional OTel backend package. The seam owns `TelemetrySharingStatus` so any backend can disclose a policy.
## Consequences
The acknowledgement is user-visible: it names the receiving session and reports the current sharing policy, honest about the fire-and-forget handoff. Package tests pin the sentence for each status and for the absent-service case; the assembled-browser e2e mounts the shipped telemetry row in FULL mode against a local dead endpoint and pins the shipped default sentence (`Session sharing is enabled.`) as a golden. The seam member is required, so a mounted backend always discloses a policy and the "not configured" sentence truthfully means no telemetry service; the `/feedback` command keeps working with no telemetry mounted. A still-blank web session renders no command row, so feedback recorded before the first message gets no visible acknowledgement (documented under the package README's limitations).
@@ -0,0 +1,27 @@
# Agent Note: 反馈确认中的会话共享披露
Status: implemented
[English](2026-08-07-feedback-acknowledgement-sharing-disclosure.md) | 中文
## 问题
`/feedback` 命令会记录一个仅写入日志的 `feedback/record` 事件并确认用户,但确认文本没有携带关于会话去向的持久信息:挂载了会话遥测(`FULL``FEEDBACK_ONLY``DISABLED`)的部署无法告知用户其反馈和会话是否离开了进程,确认文本也没有回显接收会话的 id。命令插件无法读取共享策略,因为遥测 seam 只暴露采集能力,而 OTel 模式枚举位于可选的后端包中。
## 决策
遥测 seam`@deepseek-ai/dsh-session-telemetry`)现在拥有与后端无关的共享词汇:`TelemetrySharingStatus``full` | `feedback-only` | `disabled`),并在 `Telemetry` 服务类上增加一个必需的抽象 `sharing` 成员——每个后端都必须披露其策略,因此消费方只有在未挂载任何遥测服务时才渲染「未配置」。`@deepseek-ai/dsh-session-telemetry-otel` 在构造函数中把序列化的 `TelemetryMode`(模式语义由[反馈门控投递决策](2026-08-05-feedback-gated-session-telemetry.md)负责)映射到该状态并披露,包括 `DISABLED` 模式。`/feedback` 处理器通过插件上下文读取已挂载的服务(`ctx.get('telemetry')`,绝不是声明的注入,因此命令在无遥测时也能加载和运行),并在确认文本后追加一句共享披露:`Feedback recorded for session {id}. <句子>`。无服务 → `Session sharing is not configured.``disabled``Session sharing is disabled.``feedback-only``Session sharing is feedback-gated; recording feedback releases the session prefix for sharing.``full``Session sharing is enabled.`
披露只陈述当前的共享策略,绝不承诺投递或留存:交接是后端的非阻塞入队,批处理、重试与丢失策略仍归后端 SDK,且后续重新配置可能改变已共享的内容,因此句子不声称任何内容已到达采集端,也不声称未来的留存。披露不新增任何会话事件,也绝不会进入模型 surface;Web 客户端通过现有的命令行(`CommandNode` 的结果文本)原样渲染,无需客户端改动。
## 备选方案
**客户端新增状态 RPC 与徽标。** 拒绝,因为确认文本由宿主生成,Web 客户端已经在命令行中原样渲染命令结果文本;单独的 RPC 会在第二个 surface 重复该状态,并为一句文案新增线上契约。
**在 `command-feedback` 中声明 `telemetry` 注入。** 拒绝,因为遥测是可选的:服务缺失时声明注入会导致插件加载失败,而命令必须在无遥测时可用。插件改为在处理器执行时用 `ctx.get('telemetry')` 读取服务。
**由 OTel 包拥有词汇。** 拒绝,因为 `command-feedback` 不能依赖可选的 OTel 后端包。seam 拥有 `TelemetrySharingStatus`,任何后端都能披露策略。
## 后果
确认文本对用户可见:它点名接收会话并报告当前的共享策略,如实说明 fire-and-forget 交接。包级测试为每种状态以及无服务场景固定句子;组装浏览器 e2e 以 FULL 模式挂载随附的遥测行(指向本地 dead 端点),并以 golden 固定随附默认句子(`Session sharing is enabled.`)。seam 成员是必需的,因此已挂载的后端总会披露策略,「未配置」句子如实地表示没有遥测服务;`/feedback` 命令在未挂载遥测时仍能正常工作。仍为空白的新 Web 会话不渲染命令行,因此首条消息之前记录的反馈没有可见确认(已在包 README 的限制中记录)。
@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.md
2026-07-28-experimental-plugin-package-group.md: 1ebae5dbb16d4c966f94ffde69fb0cb9bc163d80
2026-07-28-experimental-plugin-package-group.zh.md: 2d09451c5069a775906e5bc8748c334c29008164
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.md
2026-08-10-creator-guidance-introduce-cue.md: 888fee7b3def585ed3098fedcb7bc6169ee26a22
2026-08-10-creator-guidance-introduce-cue.zh.md: d80260abd1995df1f95e3f24fefcb265bda64c11
@@ -0,0 +1,33 @@
# Agent Note: Creator guidance lands as an introduce cue on the preset chip
Status: implemented
English | [中文](2026-08-10-creator-guidance-introduce-cue.zh.md)
## Problem
Authoring a preset happens inside a Creator-mode session, but the settings section gave no path into that fact. The creator entry sat outside the roster groups, the custom group vanished entirely while it had no member, and clicking the entry dropped the user onto the new-session screen with nothing marking what had changed: the staged preset chip rendered exactly as if the user had picked it by hand. Users reported not understanding that the flow had moved, or that the session they were about to start was the place where the preset gets built (#2184).
## Decision
The custom group stays on screen while empty — heading plus the creator entry, which lives inside the group as the standing "your preset will appear here" affordance rather than floating below the roster.
A pick staged from another screen carries a one-shot `introduce` flag through the seat store (`stage(id, introduce)`), and the chip announces it: the preset icon eases in over 150ms, then the name's characters fade up on a stagger the moment the icon lands. The stagger is capped twice — 40ms per tick for short CJK names, and one shared 200ms reveal window (`min(40, 200/(n-1))`) so a long Latin name finishes in the same time as its CJK counterpart instead of dragging the run out per character. CSS owns the motion; the component arms it and acknowledges the cue once the run is over, so the flag never replays on a later mount. `prefers-reduced-motion` and an empty display name acknowledge immediately with no run.
The cue is pure presentation: it is client-side seat-store state, never a session event, because the model-visible composition is already carried by the staged preset itself.
## Alternatives considered
**A toast or callout on the new-session screen.** It explains more, but it points at nothing — the chip is the artifact the user must find again later, and a dismissable box teaches the box, not the control. The cue puts the motion on the control itself.
**A fixed per-character tick.** The first implementation used 60ms per character unconditionally; an English preset name took over three times as long as its four-character Chinese counterpart, reading as lag rather than emphasis. The shared reveal window makes duration a property of the cue, not of the locale.
**Animating the pick inside the settings dialog before leaving.** The dialog closes as part of the gesture — leaving settings is how the flow says the work happens in the session — so anything played there would be cut off or would delay the navigation it exists to explain.
## Consequences
The intro timeline lives in two places that must agree: the component's `INTRO_TEXT_DELAY_MS` and the `.introIcon` CSS animation duration. The component's constants are the source of the character delays and the acknowledgement timeout; the CSS comment names the coupling. The seat store gains one bit of UI state (`introduce`) that every stage decides explicitly, and the section keeps rendering a group with no members — a shape the section golden and unit tests now pin.
## Testing
Component tests pin the capped stagger (11-character Latin name at 20ms steps, 4-character CJK name at the 40ms tick, single character with no stagger), the acknowledgement timing, and the reduced-motion and empty-name skips. `apply.spec.ts` drives the cross-screen stage end to end: the creator draft stages with the cue set, one acknowledgement clears it, and a repeat acknowledgement leaves the snapshot untouched. The `agent-preset-authoring` web e2e holds the empty custom group (heading plus creator entry) in its goldens.
@@ -0,0 +1,33 @@
# Agent Note: 创造模式引导以介绍动效落在预设 chip 上
Status: implemented
[English](2026-08-10-creator-guidance-introduce-cue.md) | 中文
## 问题
预设的创作发生在创造模式 session 内部,但设置分区没有把这条路径讲清楚。创建入口游离在名册分组之外;自定义分组在没有成员时整个消失;点击入口后用户被抛到新会话屏幕,没有任何标记说明发生了什么变化:暂存的预设 chip 渲染得和用户亲手挑选时一模一样。用户反馈看不懂流程已经移动,也不明白即将开始的 session 正是构建预设的地方(#2184)。
## 决定
自定义分组在空的时候也常驻屏幕——分组标题加创建入口,入口移入分组内部,作为"你的预设会出现在这里"的常设指引,而不是漂在名册下方。
从另一屏幕暂存的选择会经由 seat store 携带一次性的 `introduce` 标志(`stage(id, introduce)`),chip 据此自我介绍:预设图标在 150ms 内缓入,落定的瞬间名称逐字符错峰浮现。错峰有两重上限——短的中文名按每字符 40ms 的节拍,同时共享一个 200ms 的整体揭示窗口(`min(40, 200/(n-1))`),让长的拉丁名与中文名在相同时间内完成,而不是按字符数拖长整轮动画。动效由 CSS 负责;组件只负责触发,并在一轮结束后确认该提示,因此标志不会在后续挂载时重放。`prefers-reduced-motion` 与空显示名会立即确认、不播放动画。
该提示纯属呈现层:它是客户端 seat-store 状态,永远不是 session 事件,因为模型可见的组合已由暂存的预设本身承载。
## 曾考虑的替代方案
**在新会话屏幕上弹 toast 或提示框。** 它能解释更多,但什么也没指向——chip 才是用户之后必须再次找到的对象,可关闭的提示框教会的是提示框本身,不是控件。介绍动效把动作放在控件本体上。
**固定的每字符节拍。** 第一版实现无条件使用每字符 60ms;英文预设名的时长超过四字中文名的三倍,读起来像卡顿而非强调。共享揭示窗口让时长成为提示的属性,而不是语言的属性。
**离开前在设置对话框内播放选中动画。** 关闭对话框本身就是这个手势的一部分——离开设置正是流程在表达"工作发生在 session 里"——在那里播放的任何内容要么被截断,要么会拖延它本要解释的跳转。
## 后果
介绍时间线存在于两处且必须一致:组件的 `INTRO_TEXT_DELAY_MS``.introIcon` 的 CSS 动画时长。组件常量是字符延迟与确认超时的来源;CSS 注释点明了这层耦合。seat store 多出一位 UI 状态(`introduce`),每次暂存都显式决定它;分区则会渲染没有成员的分组——这一形态现由分区 golden 与单元测试钉住。
## 测试
组件测试钉住带上限的错峰(11 字符拉丁名走 20ms 步进、4 字中文名走 40ms 节拍、单字符无错峰)、确认时机,以及 reduced-motion 与空名的跳过路径。`apply.spec.ts` 端到端驱动跨屏暂存:创造模式草稿携带提示暂存,一次确认将其清除,重复确认让快照原样不动。`agent-preset-authoring` web e2e 在 golden 中保持空自定义分组(标题加创建入口)。
@@ -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-10-event-directed-pr-review-status.md
2026-08-10-event-directed-pr-review-status.md: 9db9c64fc87c1701028ae825357c3cbd7fef44d1
2026-08-10-event-directed-pr-review-status.zh.md: 381a3f64a62930a584f48cfbc3571679bbcbcef7
@@ -0,0 +1,41 @@
# Agent Note: Event-directed PR review status commands
Status: implemented
English | [中文](2026-08-10-event-directed-pr-review-status.zh.md)
## Problem
The Issue Project status records who owns the next step of resolving work. Aggregate pull-request review state answers whether GitHub considers the pull request mergeable, but it cannot represent that handoff: an earlier `CHANGES_REQUESTED` review can remain effective after the author fixes the code and requests review again.
A monotonic projection also cannot return an automation-owned Issue from `In review` to `In progress` when a reviewer requests changes. Reconstructing review rounds or reviewer blockers would add state that the required two-event contract does not need.
## Decision
The Issue lifecycle workflow treats review webhooks as commands. `pull_request.review_requested`, including a repeated request, targets `In review`. `pull_request_review.submitted` targets `In progress` only when `review.state` is `changes_requested`; the submitted event remains necessary because a reviewer can request changes without an earlier review-request event. Approved and commented submissions skip their lifecycle job before it creates a Project token, while dismissed reviews are not subscribed.
Ordinary subscribed pull-request events remain forward-only implementation signals: they can move `Inbox`, `Backlog`, or `Ready` to `In progress`, but they cannot move `In review` backward. Review-request commands can move any earlier active status to `In review`. Changes-requested commands can move earlier active statuses forward to `In progress` and can move `In review` back only when the latest status event for the target Project was written by the configured lifecycle actor. A human or unknown latest actor preserves the current status.
The handler resolves only exact same-repository `Fixes`, `Closes`, or `Resolves` references. It does not alter terminal statuses, add an Issue with no Project status, depend on PR metadata validity, query `reviewDecision`, reconstruct review rounds, look up pull requests from Issues, or run a scheduled reconciler.
[Issue lifecycle](../../../../.github/workflows/issue-lifecycle.yml) remains unsubscribed from `pull_request.ready_for_review`; neither event command depends on that action. [Issue policy](../../../../.github/workflows/issue-policy.yml) retains `ready_for_review` because it owns required-check enforcement when a human pull request enters review.
## Verification
[Issue-management tests](../../../../.github/issue-management/policy.test.mjs) pin the event-to-command mapping, the repeated-review-request transition after a changes-requested command, the changes-requested regression, terminal protection, and human override preservation. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) pin the subscribed events, the changes-requested job condition, and the separate `ready_for_review` policy trigger.
## Alternatives considered
**Derive status from `reviewDecision` or a reconstructed review round.** GitHub's aggregate can remain `CHANGES_REQUESTED` after a repeated review request, while a round reducer introduces reviewer and ordering semantics beyond the two explicit handoffs.
**Keep the forward-only projection.** Monotonic advancement protects later statuses, but it leaves an Issue in `In review` while the author is implementing requested changes.
**Apply every review command unconditionally.** This is the smallest event handler, but it lets automation overwrite a human-owned Project status. The latest target-Project status actor therefore guards the only backward transition.
**Restore `ready_for_review` or add a debounce queue.** Ready status carries neither review handoff, while another queue adds latency and control-plane state without changing either command.
## Consequences
A repeated review request moves an automation-managed resolving Issue to `In review` even while GitHub still reports an older blocking review. A later changes-requested review returns it to `In progress`; approval, comments, dismissal, pushes, and reviewer removal leave the most recent command's status unchanged.
The projection remains event-driven and does not repair an event that never runs. Replaying an old workflow run can replay its old command, and ProjectV2 still provides no atomic compare-and-swap between the latest-state read and mutation. Per-pull-request workflow concurrency and the human-ownership guard reduce these races without introducing durable lifecycle state.
@@ -0,0 +1,41 @@
# Agent Note: 由事件直接指定的 PR 评审状态命令
Status: implemented
[English](2026-08-10-event-directed-pr-review-status.md) | 中文
## 问题
Issue 所在 Project 中的状态记录了解决工作的下一步由谁负责。PR(Pull Request)的汇总评审状态可以回答 GitHub 是否认为该 PR 可合并,却无法表示这次交接:作者修复代码并重新请求评审后,先前的 `CHANGES_REQUESTED` 评审仍可能继续生效。
单调投影也无法在评审人提出修改要求时,将由自动化管理的 Issue 从 `In review` 退回 `In progress`。重建评审轮次或评审人阻塞项会引入既定双事件约定并不需要的状态。
## 决策
Issue 生命周期工作流把评审 webhook 视为命令。`pull_request.review_requested`(包括重复请求)将目标状态指定为 `In review``pull_request_review.submitted` 将目标状态指定为 `In progress`,但仅在 `review.state``changes_requested` 时生效;submitted 事件仍不可省略,因为评审人即使没有先触发 review-request 事件,也可以直接提出修改要求。对于 approved 和 commented 提交,工作流会在生命周期作业创建 Project token 前跳过该作业;dismissed 评审则不在订阅范围内。
工作流订阅的普通 PR 事件仍是只向前推进的实现信号:它们可以将 `Inbox``Backlog``Ready` 推进至 `In progress`,但不能让 `In review` 倒退。请求评审命令可将任意较早的活跃状态推进至 `In review`。请求修改命令可将较早的活跃状态推进至 `In progress`;它也可以让 `In review` 状态回退,但仅在目标 Project 的最新状态事件由配置的生命周期执行主体写入时进行。若最新状态事件的执行主体是人工用户或未知主体,则保留当前状态。
处理器仅解析同一仓库内严格匹配的 `Fixes``Closes``Resolves` 引用。它不会更改终态、将没有 Project 状态的 Issue 添加到 Project、依赖 PR 元数据是否有效、查询 `reviewDecision`、重建评审轮次、从 Issue 反向查找 PR,或运行定时协调器。
[Issue 生命周期](../../../../.github/workflows/issue-lifecycle.yml)仍不订阅 `pull_request.ready_for_review`;两条事件命令均不依赖该动作。[Issue 策略](../../../../.github/workflows/issue-policy.yml)保留 `ready_for_review`,因为人工提交的 PR 进入评审时,该工作流负责执行必需检查门禁。
## 验证
[Issue 管理测试](../../../../.github/issue-management/policy.test.mjs)锁定事件到命令的映射、请求修改命令后重复请求评审所触发的状态转换、请求修改后的状态回退、终态保护,以及保留人工覆盖状态。[工作流测试](../../../../scripts/ci-workflow.spec.ts)锁定订阅事件、请求修改作业的条件,以及独立的 `ready_for_review` 策略触发器。
## 考虑过的替代方案
**根据 `reviewDecision` 或重建的评审轮次派生状态。** GitHub 的汇总状态在重复请求评审后仍可能保持为 `CHANGES_REQUESTED`,而轮次归约器会引入超出两个显式交接动作所需范围的评审人语义和顺序语义。
**保留只向前推进的投影。** 单调推进可保护较后的状态不被回退,但作者正在按要求修改代码时,Issue 会一直停留在 `In review`
**无条件应用每条评审命令。** 这是最精简的事件处理器,但会让自动化覆盖由人工管理的 Project 状态。因此,处理器通过目标 Project 最新状态事件的执行主体保护唯一允许的回退转换。
**恢复 `ready_for_review` 或添加防抖队列。** Ready 状态并不表示两种评审交接中的任何一种;新增队列只会增加延迟和控制平面状态,不会改变任一命令。
## 后果
即使 GitHub 仍报告一个较早的阻塞性评审,重复请求评审也会将正由当前 PR 解决且由自动化管理的 Issue 推进至 `In review`。后续提出修改要求的评审会将其退回 `In progress`;批准、评论、撤销评审、推送和移除评审人都不会改变最近一条命令设定的状态。
投影仍由事件驱动;如果某个事件从未触发工作流运行,投影不会自行修复。回放旧的工作流运行可能会再次执行其中的旧命令;ProjectV2 仍不提供在读取最新状态与执行变更之间进行原子比较并交换(compare-and-swap)的能力。以单个 PR 为粒度的工作流并发控制和人工状态所有权保护机制可减少这些竞态,而无需引入持久化生命周期状态。
@@ -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-11-remove-empty-experimental-package-group.md
2026-08-11-remove-empty-experimental-package-group.md: e5e81e3e3763f216921b3f3b74709b64be3dee37
2026-08-11-remove-empty-experimental-package-group.zh.md: d44d0daaf346a5fea317f8c8c6a23f26eaa3cec0
@@ -0,0 +1,37 @@
# Agent Note: Remove the empty experimental package group
Status: implemented
English | [中文](2026-08-11-remove-empty-experimental-package-group.zh.md)
## Problem
The package hierarchy reserves `packages/experimental/` for prototypes and internal-only plugins, but no package has used the group. The empty group adds placement, dependency, promotion, and release rules without a current package or release mechanism that needs them.
The original group aimed to let the team share prototypes against the real plugin graph without implying product support. That need remains possible, but it does not justify a permanent repository category before a concrete package exists.
## Decision
The package hierarchy has no reserved experimental or internal-only group. Packages continue to live in groups selected for their current product role.
A concrete package that needs different release, stability, or dependency treatment requires a decision based on its actual consumers and release mechanism. That decision may reintroduce a dedicated group when it can also define and enforce the exclusion rules.
This note consolidates and supersedes the experimental-package-group decision, whose active triplet is removed with the empty directory.
## Alternatives considered
**Keep the empty group.** It provides an obvious future incubation location, but it also keeps repository rules with no current owner, package, or enforcement mechanism.
**Move the experimental rules into the general package instructions.** This preserves the policy without an empty directory, but makes every package change carry rules for a hypothetical package class.
**Put concrete experimental packages in product-role groups with README labels.** This preserves product-role colocation, but labels alone cannot enforce release and runtime-dependency rules. A future package can evaluate this option against its actual release mechanism.
**Treat every package as experimental until the first tagged release.** This applies a broad temporary status without providing durable treatment for packages that remain experimental after releases begin.
**Require prototypes to stay outside the repository.** This would lose access to the real plugin graph, examples, snapshots, and lifecycle checks. Removing the reserved group does not impose that restriction; a concrete prototype can establish the placement it needs.
## Consequences
The hierarchy loses an unused group and its special release and dependency policy. It also gives up a predeclared location for team discovery and a ready-made promotion path.
The first package that needs experimental or internal-only treatment must define where it lives, how releases exclude it, which runtime dependencies are allowed, and what condition promotes or removes it. A dedicated group can return when those rules have a current consumer and enforceable mechanism.
@@ -0,0 +1,37 @@
# Agent Note: 移除空的实验性包分组
Status: implemented
[English](2026-08-11-remove-empty-experimental-package-group.md) | 中文
## 问题
包层级结构预留 `packages/experimental/` 用于原型和内部专用插件,但从未有包使用该分组。这个空分组添加了放置、依赖、提升和发布规则,却没有需要这些规则的现有包或发布机制。
原分组旨在让团队基于真实插件图共享原型,同时不暗示产品会提供支持。这项需求将来可能出现,但在具体包出现前,不足以支持一个永久的仓库类别。
## 决策
包层级结构不再预留实验性或内部专用分组。包继续按照当前产品职责放入对应分组。
如果具体包需要不同的发布、稳定性或依赖处理,必须根据其实际消费方和发布机制做出决策。只要该决策同时定义并强制执行排除规则,就可以重新引入专用分组。
本 Agent Note 整合并取代实验性包分组决策;该旧决策的活跃三文件组随空目录一并移除。
## 考虑过的替代方案
**保留空分组。** 它为未来孵化工作提供明确位置,但也会保留没有当前负责人、包或强制执行机制的仓库规则。
**将实验性规则移入通用包指令。** 这可以在不保留空目录的情况下延续政策,但会让每次包变更都携带针对假设包类别的规则。
**将具体实验性包放入产品职责分组,并用 README 标注。** 这会保持产品职责共置,但仅靠标注无法强制执行发布和运行时依赖规则。未来的包可以根据实际发布机制评估此选项。
**在首个带标签的版本发布前,将每个包都视为实验性。** 这会施加宽泛的临时状态,却无法为发布开始后仍处于实验状态的包提供持久处理方式。
**要求原型留在仓库外。** 这会失去真实插件图、示例、快照和生命周期检查。移除预留分组并不施加这项限制;具体原型可以建立自身所需的放置规则。
## 后果
包层级结构移除了未使用的分组及其特殊发布和依赖政策,同时也放弃了预先声明的团队发现位置和现成的提升路径。
第一个需要实验性或内部专用处理的包必须定义其存放位置、发布版本如何排除它、允许哪些运行时依赖,以及包在何种条件下获得提升或被移除。当这些规则具有当前消费方和可强制执行的机制时,可以恢复专用分组。
+1
View File
@@ -3,6 +3,7 @@
"repository": "deepseek-harness",
"projectNumber": 1,
"projectTitle": "DSH Issue Management",
"lifecycleActor": "dsh-issue-management",
"priorityField": "Priority",
"allowUnassignedOwner": true,
"statuses": [
+111 -28
View File
@@ -37,10 +37,21 @@ const LEGACY_LABELS = new Set([
])
const TERMINAL_STATUSES = new Set(['Done', 'No action'])
const ACTIVE_STATUS_ORDER = config.statuses.filter((status) => !TERMINAL_STATUSES.has(status))
const IMPLEMENTATION_PULL_REQUEST_ACTIONS = new Set([
'opened',
'edited',
'synchronize',
'reopened',
'labeled',
'unlabeled',
])
for (const status of ['In progress', 'In review']) {
if (!ACTIVE_STATUS_ORDER.includes(status)) throw new Error(`config.statuses 缺少 ${status}`)
}
if (typeof config.lifecycleActor !== 'string' || !config.lifecycleActor) {
throw new Error('config.lifecycleActor 未设置')
}
/**
* Return Markdown outside balanced details elements.
@@ -159,18 +170,48 @@ export function requiresPullRequestPolicy({
}
/**
* Derive a forward-only Issue status from the current PR phase.
* @param {string|null} currentStatus Current Project status.
* @param {{isDraft: boolean, reviewRequestCount: number, reviewCount: number}} pull PR phase.
* @returns {string|null} Status to write, or null when no forward transition exists.
* Translate a repository event into one resolving-Issue lifecycle command.
* @param {string} eventName GitHub event name.
* @param {{action?: string, review?: {state?: string}}} event GitHub event payload.
* @returns {'implementation'|'review-requested'|'changes-requested'|null} Lifecycle command.
*/
export function nextResolvingIssueStatus(currentStatus, pull) {
const target =
!pull.isDraft && (pull.reviewRequestCount > 0 || pull.reviewCount > 0)
? 'In review'
: 'In progress'
export function resolvingIssueStatusCommand(eventName, event) {
if (eventName === 'pull_request') {
if (event.action === 'review_requested') return 'review-requested'
return IMPLEMENTATION_PULL_REQUEST_ACTIONS.has(event.action) ? 'implementation' : null
}
if (
eventName === 'pull_request_review' &&
event.action === 'submitted' &&
event.review?.state?.toLowerCase() === 'changes_requested'
) {
return 'changes-requested'
}
return null
}
/**
* Plan one event-directed resolving-Issue status transition.
* @param {string|null} currentStatus Current Project status.
* @param {'implementation'|'review-requested'|'changes-requested'} command Lifecycle command.
* @param {string|null} currentStatusActor Actor that last set the current Project status.
* @returns {string|null} Status to write, or null when no permitted transition exists.
*/
export function nextResolvingIssueStatus(currentStatus, command, currentStatusActor = null) {
let target
if (command === 'review-requested') target = 'In review'
else if (command === 'implementation' || command === 'changes-requested') target = 'In progress'
else throw new Error(`未知 lifecycle command${command}`)
const currentIndex = ACTIVE_STATUS_ORDER.indexOf(currentStatus)
const targetIndex = ACTIVE_STATUS_ORDER.indexOf(target)
if (
command === 'changes-requested' &&
currentStatus === 'In review' &&
currentStatusActor === config.lifecycleActor
) {
return target
}
return currentIndex >= 0 && currentIndex < targetIndex ? target : null
}
@@ -396,9 +437,15 @@ async function issueSnapshot(number, status = undefined) {
}
}
async function projectContext(number) {
async function projectContext(number, includeStatusActor = false) {
const data = await graphql(
`query($organization: String!, $repository: String!, $number: Int!, $project: Int!) {
`query(
$organization: String!
$repository: String!
$number: Int!
$project: Int!
$includeStatusActor: Boolean!
) {
organization(login: $organization) {
projectV2(number: $project) {
id
@@ -413,6 +460,16 @@ async function projectContext(number) {
repository(owner: $organization, name: $repository) {
issue(number: $number) {
id
timelineItems(last: 100, itemTypes: [PROJECT_V2_ITEM_STATUS_CHANGED_EVENT])
@include(if: $includeStatusActor) {
nodes {
... on ProjectV2ItemStatusChangedEvent {
actor { login }
project { id }
status
}
}
}
projectItems(first: 20, includeArchived: true) {
nodes {
id
@@ -430,6 +487,7 @@ async function projectContext(number) {
repository: config.repository,
number,
project: config.projectNumber,
includeStatusActor,
},
)
const project = data.organization?.projectV2
@@ -439,7 +497,14 @@ async function projectContext(number) {
const statusField = project.fields.nodes.find((field) => field?.name === 'Status')
if (!statusField) throw new Error('Project 缺少 Status 字段')
const item = issue.projectItems.nodes.find((candidate) => candidate.project.id === project.id)
return { project, issue, statusField, item }
const latestStatusEvent = issue.timelineItems?.nodes
?.filter((event) => event?.project?.id === project.id)
.at(-1)
const statusActor =
latestStatusEvent?.status === item?.fieldValueByName?.name
? (latestStatusEvent.actor?.login ?? null)
: null
return { project, issue, statusField, item, statusActor }
}
async function projectStatus(number) {
@@ -530,12 +595,7 @@ async function auditIssue(number, extraErrors = [], status = undefined) {
return errors
}
async function pullRequestSnapshot(number) {
const pull = await api(`/repos/${config.organization}/${config.repository}/pulls/${number}`)
const [reviewRequests, reviews] = await Promise.all([
api(`/repos/${config.organization}/${config.repository}/pulls/${number}/requested_reviewers`),
api(`/repos/${config.organization}/${config.repository}/pulls/${number}/reviews?per_page=100`),
])
async function resolvingReferencesSnapshot(number, pull) {
const references = parseReferences({
body: pull.body ?? '',
repository: `${config.organization}/${config.repository}`,
@@ -547,20 +607,41 @@ async function pullRequestSnapshot(number) {
}
return {
number,
isDraft: pull.draft,
authorType: pull.user?.type ?? 'User',
reviewRequestCount: reviewRequests.users.length + reviewRequests.teams.length,
reviewCount: reviews.length,
labels: pull.labels.map((label) => label.name),
references: retainIssueReferences(references, issues),
issues,
}
}
async function advanceResolvingIssues(pull) {
async function pullRequestSnapshot(number) {
const [pull, reviewRequests, reviews] = await Promise.all([
api(`/repos/${config.organization}/${config.repository}/pulls/${number}`),
api(`/repos/${config.organization}/${config.repository}/pulls/${number}/requested_reviewers`),
api(`/repos/${config.organization}/${config.repository}/pulls/${number}/reviews?per_page=100`),
])
const resolving = await resolvingReferencesSnapshot(number, pull)
return {
...resolving,
isDraft: pull.draft,
authorType: pull.user?.type ?? 'User',
reviewRequestCount: reviewRequests.users.length + reviewRequests.teams.length,
reviewCount: reviews.length,
labels: pull.labels.map((label) => label.name),
}
}
async function lifecyclePullRequestSnapshot(number) {
const pull = await api(`/repos/${config.organization}/${config.repository}/pulls/${number}`)
return resolvingReferencesSnapshot(number, pull)
}
async function transitionResolvingIssues(pull, command) {
for (const number of pull.references.resolving) {
const context = await projectContext(number)
const target = nextResolvingIssueStatus(context.item?.fieldValueByName?.name ?? null, pull)
const context = await projectContext(number, command === 'changes-requested')
const target = nextResolvingIssueStatus(
context.item?.fieldValueByName?.name ?? null,
command,
context.statusActor,
)
if (!target) continue
// TODO: Replace this latest-state guard with per-Issue serialization or a
// conditional ProjectV2 update; GraphQL currently has no compare-and-swap.
@@ -598,8 +679,10 @@ async function runLifecycle(eventName, event) {
}
if (eventName === 'pull_request' || eventName === 'pull_request_review') {
const pull = await pullRequestSnapshot(event.pull_request.number)
await advanceResolvingIssues(pull)
const command = resolvingIssueStatusCommand(eventName, event)
if (!command) return
const pull = await lifecyclePullRequestSnapshot(event.pull_request.number)
await transitionResolvingIssues(pull, command)
}
}
+65 -23
View File
@@ -6,6 +6,7 @@ import {
nextResolvingIssueStatus,
parseReferences,
retainIssueReferences,
resolvingIssueStatusCommand,
requiresPullRequestPolicy,
validateBody,
validateIssue,
@@ -243,32 +244,73 @@ test('requires policy only after a human PR enters review', () => {
)
})
test('advances resolving Issues to the live PR phase', () => {
const draft = { isDraft: true, reviewRequestCount: 1, reviewCount: 4 }
const open = { isDraft: false, reviewRequestCount: 0, reviewCount: 0 }
const requestedReview = { isDraft: false, reviewRequestCount: 1, reviewCount: 0 }
const submittedReview = { isDraft: false, reviewRequestCount: 0, reviewCount: 1 }
for (const status of ['Inbox', 'Backlog', 'Ready']) {
assert.equal(nextResolvingIssueStatus(status, draft), 'In progress')
assert.equal(nextResolvingIssueStatus(status, open), 'In progress')
assert.equal(nextResolvingIssueStatus(status, requestedReview), 'In review')
assert.equal(nextResolvingIssueStatus(status, submittedReview), 'In review')
test('maps only explicit review handoffs to review status commands', () => {
assert.equal(
resolvingIssueStatusCommand('pull_request', {
action: 'review_requested',
}),
'review-requested',
)
assert.equal(
resolvingIssueStatusCommand('pull_request_review', {
action: 'submitted',
review: { state: 'changes_requested' },
}),
'changes-requested',
)
for (const state of ['approved', 'commented']) {
assert.equal(
resolvingIssueStatusCommand('pull_request_review', {
action: 'submitted',
review: { state },
}),
null,
)
}
assert.equal(nextResolvingIssueStatus('In progress', requestedReview), 'In review')
assert.equal(nextResolvingIssueStatus('In progress', submittedReview), 'In review')
assert.equal(
resolvingIssueStatusCommand('pull_request_review', {
action: 'dismissed',
review: { state: 'changes_requested' },
}),
null,
)
})
test('never regresses or reopens a resolving Issue', () => {
const implementation = { isDraft: false, reviewRequestCount: 0, reviewCount: 0 }
const review = { isDraft: false, reviewRequestCount: 0, reviewCount: 1 }
test('keeps ordinary pull request events as forward-only implementation signals', () => {
for (const action of ['opened', 'edited', 'synchronize', 'reopened', 'labeled', 'unlabeled']) {
assert.equal(resolvingIssueStatusCommand('pull_request', { action }), 'implementation')
}
assert.equal(
resolvingIssueStatusCommand('pull_request', { action: 'review_request_removed' }),
null,
)
})
assert.equal(nextResolvingIssueStatus('In progress', implementation), null)
assert.equal(nextResolvingIssueStatus('In review', implementation), null)
assert.equal(nextResolvingIssueStatus('In review', review), null)
assert.equal(nextResolvingIssueStatus('Done', review), null)
assert.equal(nextResolvingIssueStatus('No action', review), null)
assert.equal(nextResolvingIssueStatus(null, review), null)
test('toggles automation-owned work on request changes and repeated review request', () => {
for (const status of ['Inbox', 'Backlog', 'Ready']) {
assert.equal(nextResolvingIssueStatus(status, 'implementation'), 'In progress')
assert.equal(nextResolvingIssueStatus(status, 'review-requested'), 'In review')
assert.equal(nextResolvingIssueStatus(status, 'changes-requested'), 'In progress')
}
let status = nextResolvingIssueStatus(
'In review',
'changes-requested',
'dsh-issue-management',
)
assert.equal(status, 'In progress')
status = nextResolvingIssueStatus(status, 'review-requested')
assert.equal(status, 'In review')
})
test('preserves human review status and terminal Issues', () => {
assert.equal(nextResolvingIssueStatus('In progress', 'implementation'), null)
assert.equal(nextResolvingIssueStatus('In review', 'implementation'), null)
assert.equal(nextResolvingIssueStatus('In review', 'review-requested'), null)
assert.equal(nextResolvingIssueStatus('In review', 'changes-requested', 'tianyicui'), null)
assert.equal(nextResolvingIssueStatus('In review', 'changes-requested'), null)
assert.equal(nextResolvingIssueStatus('Done', 'review-requested'), null)
assert.equal(nextResolvingIssueStatus('No action', 'changes-requested'), null)
assert.equal(nextResolvingIssueStatus(null, 'review-requested'), null)
})
test('keeps lifecycle projection independent of PR metadata enforcement', () => {
@@ -283,7 +325,7 @@ test('keeps lifecycle projection independent of PR metadata enforcement', () =>
}
assert.ok(validatePullRequest(pull).length > 0)
assert.equal(nextResolvingIssueStatus('Inbox', pull), 'In review')
assert.equal(nextResolvingIssueStatus('Inbox', 'review-requested'), 'In review')
})
test('exempts Draft, Bot, and App PRs', () => {
+1
View File
@@ -36,6 +36,7 @@ concurrency:
jobs:
lifecycle:
name: Issue lifecycle
if: ${{ github.event_name != 'pull_request_review' || (github.event.action == 'submitted' && github.event.review.state == 'changes_requested') }}
runs-on: ubuntu-latest
steps:
- name: Check out trusted policy
+4 -2
View File
@@ -176,8 +176,10 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
await expect.poll(async () => dialog.getByText('我的模式').count(), { timeout: 10_000 }).toBe(0)
expect(existsSync(join(userRoot, 'my-agent'))).toBe(false)
// Custom group gone with its only member; the shipped set stands.
expect(await dialog.getByRole('heading', { name: '自定义' }).count()).toBe(0)
// The custom group outlives its only member: the heading stays with the
// creator entry so the place to author a preset never disappears.
expect(await dialog.getByRole('heading', { name: '自定义' }).count()).toBe(1)
expect(await dialog.getByRole('button', { name: '用「创造模式」创作自定义预设' }).count()).toBe(1)
expect(await dialog.getByText('标准模式').count()).toBeGreaterThan(0)
}, 60_000)
+101
View File
@@ -0,0 +1,101 @@
// Keyless assembled-browser coverage for the /feedback command over the
// shipped Web bundles and the real host wire. The command plane settles
// without a model turn: the host appends the log-only command/run +
// feedback/record + command/done lifecycle, and the transcript renders the
// acknowledgement — the recorded session id plus the session-sharing
// disclosure — as a persistent command row. The scaffold mounts the shipped
// telemetry row in FULL mode against a local dead endpoint (no record leaves
// the process), so the golden pins the shipped default sentence
// `Session sharing is enabled.`; the per-status sentences are pinned by the
// package and OTel unit tests.
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'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/feedback-command', import.meta.url))
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
const ACK_EXPECTED = join(SNAPSHOT_DIR, 'ack.expected.md')
const MODE = webSnapshotMode()
// Discard port: loopback listener never binds, so FULL telemetry discloses
// the shipped default policy without any record reaching a collector.
const TELEMETRY_URL = 'http://127.0.0.1:9/v1/logs'
const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.'
describe('web e2e: /feedback command acknowledgement', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({
telemetryUrl: TELEMETRY_URL,
...(MODE === 'record' ? {} : { replayFixture: FIXTURE }),
})
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
// Fresh world: connecting a workspace births the blank session whose
// live composer accepts the slash line.
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('drives the recorded prompt to a settled turn (all modes)', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-feedback-drive'))
if (MODE !== 'record') {
// Drift guard: the committed fixture must carry exactly the drive prompt.
expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
}
const input = page.locator('textarea').first()
await input.waitFor({ timeout: 10_000 })
// Arm the turn-boundary waiter BEFORE sending, so a burst replay cannot
// miss the turn/end that settles the recorded turn.
const settled = scaffold.whenTurnSettled()
await input.fill(PROMPT)
await input.press('Enter')
const sessionId = await settled
if (MODE === 'record') {
await recordFixture(scaffold, sessionId, FIXTURE)
}
}, 60_000)
it.skipIf(MODE === 'record')('records feedback and renders the acknowledgement with session id and sharing status', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-feedback-command'))
// The drive test settled the recorded turn: the transcript is active (a
// command row does not render while a fresh session is still blank) and
// the replayed reply is on screen.
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 })
const input = page.locator('textarea').first()
await input.fill('/feedback the diff view is unreadable')
await input.press('Enter')
// The command plane settles without a model turn: the ack row names the
// recorded session and the mounted FULL backend's disclosure.
await page.getByText(/Feedback recorded for session/).waitFor({ timeout: 10_000 })
expect(await page.getByText(/Session sharing is enabled/).count()).toBe(1)
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(ACK_EXPECTED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 60_000)
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ack.expected.md'])
})
})
+1
View File
@@ -128,6 +128,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions-aria'))
await page.getByRole('button', { name: /^Select model, current/ })
.waitFor({ timeout: 10_000 })
await page.getByText(/Cache hit \d+%/u).first().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.
await page.getByRole('button', { name: 'Copy' }).first().focus()
+13 -2
View File
@@ -245,6 +245,13 @@ export interface LaunchOptions {
}
/** Leave the current welcome notice unacknowledged; ordinary scenarios publish it as complete before browser boot. */
welcomeNoticePending?: boolean
/**
* Mount the shipped telemetry row in FULL mode against this exporter URL
* instead of disabling it. Used to pin a real backend disclosure in
* assembled coverage; point the URL at a local dead endpoint so no record
* leaves the process.
*/
telemetryUrl?: string
/**
* Browse through a trusted non-loopback hostname that the browser resolves
* to loopback (for example `*.localhost`). The test server stays bound to
@@ -334,6 +341,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
} catch (error) {
const failures: unknown[] = [error]
await rm(workspaceCwd, { recursive: true, force: true }).catch((cleanupError: unknown) => failures.push(cleanupError))
restoreSkillRootEnvironment()
if (failures.length > 1) throw new AggregateError(failures, 'web scaffold temp-root setup failed')
throw error
}
@@ -395,8 +403,11 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
{ id: 'session-title-llm', disabled: true },
// Fixture sessions must never leave the process: the shipped row defaults
// to the production OTLP endpoint (or whatever DSH_TELEMETRY_OTLP_URL
// names in the ambient environment).
{ id: 'telemetry-otel', disabled: true },
// names in the ambient environment). A scenario that pins a real backend
// disclosure passes a local dead endpoint instead of disabling the row.
options.telemetryUrl === undefined
? { id: 'telemetry-otel', disabled: true }
: { id: 'telemetry-otel', config: { exporter: { url: options.telemetryUrl }, shutdownTimeoutMillis: 1_000 } },
{
id: 'webserver',
config: { host: '127.0.0.1', port: 0 },
+2 -2
View File
@@ -468,9 +468,9 @@ describe('web e2e: seeded history renders through cold resume', () => {
if (done?.type !== 'command/done') throw new Error('feedback command did not settle')
const [sessionLine, userLine, extraLine] = done.data.text?.split('\n') ?? []
expect(sessionLine).toBe(`Feedback recorded for session ${SEED_ID}`)
expect(userLine).toMatch(/^User: [0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i)
expect(userLine).toMatch(/^User: [0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\./i)
expect(extraLine).toBeUndefined()
const userId = userLine?.slice('User: '.length)
const userId = userLine?.match(/^User: ([0-9a-f-]+)/i)?.[1]
if (userId === undefined) throw new Error('feedback command omitted the user id')
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
@@ -61,6 +61,7 @@
- 'button "复制: 创造模式"':
- img
- text: 复制
- heading "自定义" [level=3]
- button "用「创造模式」创作自定义预设":
- img
- text: 用「创造模式」创作自定义预设
@@ -0,0 +1,39 @@
- banner:
- navigation "Session hierarchy":
- button "Reply with the single word" [disabled]
- img
- text: Standard mode
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: Reply with the single word LIGHTHOUSE and stop. {{clock}}
- button "Copy":
- img
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection @deepseek-ai/dsh-system-prompt
- button "Think The user wants me to reply with a single word. Let me comply.":
- img
- img
- text: Think The user wants me to reply with a single word. Let me comply.
- paragraph: LIGHTHOUSE
- button "Copy":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
- 'button "feedback Feedback recorded for session session-{{uuid}} User: {{uuid}}. Session sharing is enabled."':
- img
- img
- text: "feedback Feedback recorded for session session-{{uuid}} User: {{uuid}}. Session sharing is enabled."
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "6% of context used"
- button "Send message" [disabled]
- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 7.8K tok · Output 21 tok
@@ -0,0 +1,17 @@
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785015039278,"cwd":"{{cwd}}/workspace"}
{"type":"turn/start","seq":0,"time":1785015039291,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
{"type":"user/message","seq":1,"time":1785015039292,"data":{"content":[{"type":"text","text":"Reply with the single word LIGHTHOUSE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
{"type":"session/title","seq":2,"time":1785015039294,"data":{"title":"Reply with the single word","messageSeqs":[1],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":3,"time":1785015039362,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":4,"time":1785015039363,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
{"type":"assistant/chunk","seq":5,"time":1785015039930,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":6,"time0":1785015039930,"data":{"turn":1,"step":1,"index":0,"dt":[162,28,1,0,0,46,1,0,0,0,11,0,0,30],"texts":["The"," user"," wants"," me"," to"," reply"," with"," a"," single"," word","."," Let"," me"," comply","."]}}
{"type":"assistant/chunk","seq":21,"time":1785015040209,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
{"type":"text-chunks","seq0":22,"time0":1785015040209,"data":{"turn":1,"step":1,"index":1,"dt":[1,0,30,1],"texts":["L","IGH","TH","O","USE"]}}
{"type":"assistant/chunk","seq":27,"time":1785015040241,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with a single word. Let me comply."}}}}
{"type":"assistant/chunk","seq":28,"time":1785015040242,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"LIGHTHOUSE"}}}}
{"type":"assistant/chunk","seq":29,"time":1785015040242,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":109,"outputTokens":21,"cacheReadTokens":7680,"reasoningTokens":15}}}}
{"type":"assistant/chunk","seq":30,"time":1785015040242,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":31,"time":1785015040244,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with a single word. Let me comply."},{"type":"text","text":"LIGHTHOUSE"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":109,"outputTokens":21,"cacheReadTokens":7680,"reasoningTokens":15}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30],"surfaceOp":"append"}
{"type":"step/end","seq":32,"time":1785015040246,"data":{"turn":1,"step":1}}
{"type":"turn/end","seq":33,"time":1785015040247,"data":{"turn":1,"reason":{"kind":"completed"}}}
@@ -38,10 +38,10 @@
- text: Context injection AGENTS.md
- img
- text: permission preset read-only
- 'button "feedback Feedback recorded for session {{seededId}} User: {{uuid}}" [expanded]':
- 'button "feedback Feedback recorded for session {{seededId}} User: {{uuid}}. Session sharing is not configured." [expanded]':
- img
- text: "feedback Feedback recorded for session {{seededId}} User: {{uuid}}"
- text: "Feedback recorded for session {{seededId}} User: {{uuid}}"
- text: "feedback Feedback recorded for session {{seededId}} User: {{uuid}}. Session sharing is not configured."
- text: "Feedback recorded for session {{seededId}} User: {{uuid}}. Session sharing is not configured."
- textbox "Message the agent"
- button "Commands":
- img
+2 -1
View File
@@ -395,7 +395,8 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
expect([
Math.round(clickAreaBox!.x - treeBox!.x),
Math.round(treeBox!.x + treeBox!.width - clickAreaBox!.x - clickAreaBox!.width),
]).toEqual([5, 5])
// Menu padding alone insets the rows now that the border is gone.
]).toEqual([4, 4])
await compareOrRefreshGolden(
BRANCHLESS_EXPECTED,
await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd),
+1
View File
@@ -65,6 +65,7 @@
"tests/agent-preset-selection.e2e.ts",
"tests/agent-preset-authoring.e2e.ts",
"tests/shipped-composition.e2e.ts",
"tests/feedback-command.e2e.ts",
"tests/startup-auto-selection.e2e.ts",
"tests/produced-files.e2e.ts",
"tests/produced-file-mentions.e2e.ts",
+1 -1
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/config-catalog.md
config-catalog.md: 119f83a524c44187c321f6bf5f86c4580c748eb5
config-catalog.md: 33974fb010b6fba8f40f699277ff890f99cd6cf6
config-catalog.zh.md: b3b776edea79b554be10a91338800dd179073c97
+1 -1
View File
@@ -1562,7 +1562,7 @@ export enum TelemetryMode {
Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) · `OTLPExporterNodeConfigBase` (`@opentelemetry/otlp-exporter-base`)
Source: [`packages/session/session-telemetry-otel/src/index.ts:79`](../packages/session/session-telemetry-otel/src/index.ts)
Source: [`packages/session/session-telemetry-otel/src/index.ts:91`](../packages/session/session-telemetry-otel/src/index.ts)
## `@deepseek-ai/dsh-session-title`
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
event-producer-consumer.md: 6d74db3f6b7309372cf7a691a39c09f3fdd05c76
event-producer-consumer.zh.md: 1879e74f3e8c01143292b82a1745f4438f64e0ac
event-producer-consumer.md: 1f50499cc055337aa6fb521702264d29bdce196f
event-producer-consumer.zh.md: d29cab8974d206b74b8869057b8efcf7542c1161
+4 -4
View File
@@ -37,10 +37,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:170`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` |
| `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:157`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:297`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:164`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), `server`, [`subagent`](../packages/subagent/subagent) |
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:138`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:144`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:155`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) |
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:165`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), `server`, [`subagent`](../packages/subagent/subagent) |
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:145`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:156`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) |
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) |
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
| `telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
+4 -4
View File
@@ -39,10 +39,10 @@
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:170`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` |
| `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:157`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:297`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:164`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), `server`, [`subagent`](../packages/subagent/subagent) |
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:138`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:144`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:155`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) |
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:165`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), `server`, [`subagent`](../packages/subagent/subagent) |
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:145`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:156`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) |
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) |
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
| `telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/module-graph.md
module-graph.md: e88839f6372ecdd1e9c0e4ac00839a036f24bc40
module-graph.zh.md: efd5ee1fe4430c6371121bf752004b65cefad28e
module-graph.md: 21bf9e958c3e0cf75d0d8412ff81de2de34114ec
module-graph.zh.md: 4f59c927b88ccfeae675d852fef0a7ad23b18709
+2 -1
View File
@@ -728,6 +728,7 @@ flowchart TD
pkg_command_feedback --> pkg_commands
pkg_command_feedback --> pkg_invariants
pkg_command_feedback --> pkg_session
pkg_command_feedback --> pkg_session_telemetry
pkg_command_feedback --> pkg_user_id
pkg_permission --> pkg_bash
pkg_permission --> pkg_commands
@@ -1388,7 +1389,7 @@ flowchart TD
| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) |
| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) |
| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-id`](../packages/session/user-id) |
| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry), [`user-id`](../packages/session/user-id) |
| [`permission`](../packages/interaction/permission) | `interaction` | [`bash`](../packages/bash/bash), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval) |
| [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`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) |
+2 -1
View File
@@ -730,6 +730,7 @@ flowchart TD
pkg_command_feedback --> pkg_commands
pkg_command_feedback --> pkg_invariants
pkg_command_feedback --> pkg_session
pkg_command_feedback --> pkg_session_telemetry
pkg_command_feedback --> pkg_user_id
pkg_permission --> pkg_bash
pkg_permission --> pkg_commands
@@ -1390,7 +1391,7 @@ flowchart TD
| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) |
| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) |
| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-id`](../packages/session/user-id) |
| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry), [`user-id`](../packages/session/user-id) |
| [`permission`](../packages/interaction/permission) | `interaction` | [`bash`](../packages/bash/bash), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval) |
| [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`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) |
+1 -1
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/persistence-catalog.md
persistence-catalog.md: f44569d3bacec0a832f4b4bca6acf4abb0846a0d
persistence-catalog.md: 1b94ecc541f2b9da216a5d10e02a8a5aa46f7cfb
persistence-catalog.zh.md: 21ed29a3da2587a604ec90d201030fd644fc5bd4
+1 -1
View File
@@ -364,7 +364,7 @@ Source: [`packages/compact/compact/src/types.ts:33`](../packages/compact/compact
'feedback/record': { text: string }
```
Source: [`packages/feedback/command-feedback/src/index.ts:25`](../packages/feedback/command-feedback/src/index.ts)
Source: [`packages/feedback/command-feedback/src/index.ts:62`](../packages/feedback/command-feedback/src/index.ts)
### `goal/*`
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/subagent.md
subagent.md: 220ecf493b2d3a84cede34c1cf72dbf0dcd80353
subagent.zh.md: b0a0df5a778ed758421225e524c87cf8ef213c0b
subagent.md: 42359215b2e21028e13734c2a4cd2488f86a07af
subagent.zh.md: 0d1d4c42fa774867679ff248bfc99b0024b04257
+11 -6
View File
@@ -293,7 +293,12 @@ The outcome of a one-shot run, resolved by `SubagentRun.result`. `structured` is
* The terminal outcome of a subagent run, resolved by {@link SubagentRun.result}.
*/
interface SubagentResult {
/** The child's final assistant output (the last assistant message's content). */
/**
* The child's final assistant output is the content of its last non-empty
* assistant message. Empty-content messages, including usage-only messages,
* are skipped. Without a non-empty message, the output is its accumulated
* assistant text stream, or `[]` when the child produced neither.
*/
readonly output: ContentBlock[]
/**
* The structured result after a requested `outputSchema` was successfully
@@ -614,7 +619,7 @@ async start(name: string, request: SubagentStartRequest): Promise<SubagentRun>
Types: [Agent](core.md) · [ContentBlock](llm-streaming.md) · [MessageId](llm-streaming.md) · [SessionId](core.md)
Source: [`packages/subagent/subagent/src/index.ts:169`](../../packages/subagent/subagent/src/index.ts)
Source: [`packages/subagent/subagent/src/index.ts:170`](../../packages/subagent/subagent/src/index.ts)
<a id="subagent-events"></a>
@@ -640,7 +645,7 @@ A published child settled. Scope-filtered dispatch uses the same delegating pare
Types: [Scoped](scope.md)
Source: [`packages/subagent/subagent/src/index.ts:164`](../../packages/subagent/subagent/src/index.ts)
Source: [`packages/subagent/subagent/src/index.ts:165`](../../packages/subagent/subagent/src/index.ts)
<a id="subagentprovider-added--emit"></a>
@@ -657,7 +662,7 @@ A provider became resolvable in the registry.
'subagent/provider-added'(provider: SubagentProvider): void
```
Source: [`packages/subagent/subagent/src/index.ts:138`](../../packages/subagent/subagent/src/index.ts)
Source: [`packages/subagent/subagent/src/index.ts:139`](../../packages/subagent/subagent/src/index.ts)
<a id="subagentprovider-removed--emit"></a>
@@ -674,7 +679,7 @@ A provider left the registry. Accepted runs remain holder-owned.
'subagent/provider-removed'(name: string): void
```
Source: [`packages/subagent/subagent/src/index.ts:144`](../../packages/subagent/subagent/src/index.ts)
Source: [`packages/subagent/subagent/src/index.ts:145`](../../packages/subagent/subagent/src/index.ts)
<a id="subagentstart--emit"></a>
@@ -698,5 +703,5 @@ A provider established a published child. For in-process providers, `ctx.agents.
Types: [Scoped](scope.md)
Source: [`packages/subagent/subagent/src/index.ts:155`](../../packages/subagent/subagent/src/index.ts)
Source: [`packages/subagent/subagent/src/index.ts:156`](../../packages/subagent/subagent/src/index.ts)
<!-- END GENERATED cordis-surface -->
+11 -6
View File
@@ -293,7 +293,12 @@ type SubagentDescendantListEntry = SubagentListEntry & {
* The terminal outcome of a subagent run, resolved by {@link SubagentRun.result}.
*/
interface SubagentResult {
/** The child's final assistant output (the last assistant message's content). */
/**
* The child's final assistant output is the content of its last non-empty
* assistant message. Empty-content messages, including usage-only messages,
* are skipped. Without a non-empty message, the output is its accumulated
* assistant text stream, or `[]` when the child produced neither.
*/
readonly output: ContentBlock[]
/**
* The structured result after a requested `outputSchema` was successfully
@@ -616,7 +621,7 @@ async start(name: string, request: SubagentStartRequest): Promise<SubagentRun>
Types: [Agent](core.md) · [ContentBlock](llm-streaming.md) · [MessageId](llm-streaming.md) · [SessionId](core.md)
Source: [`packages/subagent/subagent/src/index.ts:169`](../../packages/subagent/subagent/src/index.ts)
Source: [`packages/subagent/subagent/src/index.ts:170`](../../packages/subagent/subagent/src/index.ts)
<a id="subagent-events"></a>
@@ -642,7 +647,7 @@ A published child settled. Scope-filtered dispatch uses the same delegating pare
Types: [Scoped](scope.md)
Source: [`packages/subagent/subagent/src/index.ts:164`](../../packages/subagent/subagent/src/index.ts)
Source: [`packages/subagent/subagent/src/index.ts:165`](../../packages/subagent/subagent/src/index.ts)
<a id="subagentprovider-added--emit"></a>
@@ -659,7 +664,7 @@ A provider became resolvable in the registry.
'subagent/provider-added'(provider: SubagentProvider): void
```
Source: [`packages/subagent/subagent/src/index.ts:138`](../../packages/subagent/subagent/src/index.ts)
Source: [`packages/subagent/subagent/src/index.ts:139`](../../packages/subagent/subagent/src/index.ts)
<a id="subagentprovider-removed--emit"></a>
@@ -676,7 +681,7 @@ A provider left the registry. Accepted runs remain holder-owned.
'subagent/provider-removed'(name: string): void
```
Source: [`packages/subagent/subagent/src/index.ts:144`](../../packages/subagent/subagent/src/index.ts)
Source: [`packages/subagent/subagent/src/index.ts:145`](../../packages/subagent/subagent/src/index.ts)
<a id="subagentstart--emit"></a>
@@ -700,5 +705,5 @@ A provider established a published child. For in-process providers, `ctx.agents.
Types: [Scoped](scope.md)
Source: [`packages/subagent/subagent/src/index.ts:155`](../../packages/subagent/subagent/src/index.ts)
Source: [`packages/subagent/subagent/src/index.ts:156`](../../packages/subagent/subagent/src/index.ts)
<!-- END GENERATED cordis-surface -->
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/telemetry.md
telemetry.md: 5ea5c67210ce1387cbd886935e914baf7f904fbb
telemetry.zh.md: bd8fc8acc4c8522d8b1e4bc543431c0abf224411
telemetry.md: 97694a9a5a209224087d0d8454d83e29ce568ea4
telemetry.zh.md: 9e8b17f4bddb3debdf4dff9d3c3fed1296ebf3d7
+18 -3
View File
@@ -2,7 +2,7 @@
English | [中文](telemetry.zh.md)
Outbound session reporting is one [capability seam](../capability-seams.md): its Service Definition ([dsh-session-telemetry](../../packages/session/session-telemetry), `ctx.telemetry`) declares the minimal backend contract, and its capture coordinator owns the capture points, fixed chunk projection, `telemetry/record` redaction waterfall, and handoff cursor; the Service provider a deployment loads ([dsh-session-telemetry-otel](../../packages/session/session-telemetry-otel)) uses the OpenTelemetry JS SDK's log pipeline with its configuration unchanged. This optional capability is not part of the agent loop, and nothing here reaches a model request. The harness stops after it calls `emit()`; the reporting SDK owns batching, retry, queueing, and loss policy. The [revival Agent Note](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md) records that rule and the rejected alternatives. The [Service Definition README](../../packages/session/session-telemetry/README.md) defines the capture-point, cursor, and projection contracts.
Outbound session reporting is split as a [capability seam](../capability-seams.md): the Service Definition and capture coordinator ([dsh-session-telemetry](../../packages/session/session-telemetry), `ctx.telemetry`) own the capture points, fixed chunk projection, `telemetry/record` redaction waterfall, handoff cursor, and minimal backend contract; the Service provider a deployment loads ([dsh-session-telemetry-otel](../../packages/session/session-telemetry-otel)) is the OpenTelemetry JS SDK's log pipeline configured verbatim. It is one optional capability, not part of the agent-loop spine, and nothing here reaches a model request. The boundary axiom — the harness's aspect ends at `emit()`; batching, retry, queueing, and loss policy belong to the reporting SDK — and the rejected alternatives are pinned in the [revival Agent Note](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md); the capture points, cursor, and projection contracts live in the [Service Definition README](../../packages/session/session-telemetry/README.md).
Source: [`packages/session/session-telemetry/src/index.ts`](../../packages/session/session-telemetry/src/index.ts)
@@ -56,6 +56,21 @@ interface TelemetryRecord {
Only the first `assistant/chunk` of each `(turn, step)` ships — the stream-started signal; the rest drop at capture, so `seq` gaps are routine on the wire and never a loss signal. Every other [session event](session.md) type, including plugin-merged ones the seam never heard of, passes through whole. Delivery is best-effort: the cursor marks handed-off, not delivered, records can be lost (crash, reload window) and duplicated (cursor-less re-adoption, SDK retries), so receivers dedupe ledger records on `(session.id, event.seq)`; ops records deliberately omit that identity — they are signals to alert on, not entries to sum, and tolerate duplicates instead.
## The sharing disclosure
The seam's acknowledgement contract (owned by the [Service Definition README's sharing-disclosure section](../../packages/session/session-telemetry/README.md#the-sharing-disclosure)): every backend discloses its deployment-selected sharing policy through the required abstract `sharing` member on `ctx.telemetry`, and consumers render "not configured" only when no telemetry service is mounted. The disclosure states the current policy, never delivery or retention — handoff is the non-blocking enqueue, and batching, retry, and loss policy stay the reporting SDK's.
```ts type-equiv
/**
* Deployment-selected session-sharing policy disclosed by a mounted
* {@link Telemetry} backend to human-facing acknowledgement surfaces (the
* `/feedback` command's confirmation text). The seam owns the vocabulary so
* any backend can disclose a policy without depending on the OTel package;
* the values mirror the OTel backend's serialized `TelemetryMode` choices.
*/
type TelemetrySharingStatus = 'full' | 'feedback-only' | 'disabled'
```
## The backend contract
```ts type-equiv
@@ -104,7 +119,7 @@ interface TelemetryBackend {
}
```
`Telemetry` (`ctx.telemetry`, [signatures](#ctxtelemetry--telemetry-abstract-seam)) is the loadable form of this contract: each context accepts one implementation and throws on a duplicate. A backend constructs `TelemetryCoordinator` in its constructor to install capture.
`Telemetry` (`ctx.telemetry`, [signatures](#ctxtelemetry--telemetry-abstract-seam)) is the contract's loadable form — one implementation per context, duplicate load throws — and a backend composes the seam's `TelemetryCoordinator` in its constructor to install the capture side.
## The redact waterfall: `telemetry/record`
@@ -141,7 +156,7 @@ flush?(): void
abstract shutdown(): Promise<void>
```
Source: [`packages/session/session-telemetry/src/index.ts:139`](../../packages/session/session-telemetry/src/index.ts)
Source: [`packages/session/session-telemetry/src/index.ts:148`](../../packages/session/session-telemetry/src/index.ts)
<a id="telemetry-events"></a>
+18 -3
View File
@@ -2,7 +2,7 @@
[English](telemetry.md) | 中文
对外会话上报一项[能力 seam](../capability-seams.md)Service Definition[dsh-session-telemetry](../../packages/session/session-telemetry)`ctx.telemetry`声明最小后端约定,其捕获协调器负责捕获点、固定分片投影、`telemetry/record` 脱敏 waterfall(瀑布式事件)handoff 游标;部署方加载的 Service provider[dsh-session-telemetry-otel](../../packages/session/session-telemetry-otel)按原配置使用 OpenTelemetry JS SDK 日志流水线。这项能力可选,不属于 agent loop(智能体循环),这里也没有任何内容会进入模型请求。Harness 调用 `emit()` 后停止处理;上报 SDK 负责批处理、重试、排队丢失策略[复活 Agent Note](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)记录了这条规则和被否决的替代方案。[Service Definition README](../../packages/session/session-telemetry/README.md) 定义捕获点、游标和投影约定
对外会话上报拆分为一项[能力 seam](../capability-seams.md)Service Definition 与捕获协调器[dsh-session-telemetry](../../packages/session/session-telemetry)`ctx.telemetry`拥有捕获点、固定分片投影、`telemetry/record` 脱敏 waterfall(瀑布式事件)handoff 游标与最小后端约定;部署方加载的 Service provider[dsh-session-telemetry-otel](../../packages/session/session-telemetry-otel)则是原样配置的 OpenTelemetry JS SDK 日志流水线。它是一项可选能力,不属于 agent loop(智能体循环)主干,这里也没有任何内容会进入模型请求。边界公理(harness 的职责止于 `emit()`批处理、重试、排队丢失策略都属于上报 SDK)连同被否决的替代方案,均已在[复活 Agent Noteagent 决策记录)](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)中定案;捕获点、游标与投影的约定见 [Service Definition README](../../packages/session/session-telemetry/README.md)。
源码:[`packages/session/session-telemetry/src/index.ts`](../../packages/session/session-telemetry/src/index.ts)
@@ -56,6 +56,21 @@ interface TelemetryRecord {
每个 `(turn, step)` 只发出第一条 `assistant/chunk`,即「流已开始」的信号;其余分片在捕获时丢弃,因此导出流中的 `seq` 缺口是常态,绝不是丢失信号。其他所有[会话事件](session.md)类型都会完整透传,包括该 seam 从未听说过、由插件合并进来的事件类型。投递是尽力而为的:游标标记的是「已交接」而非「已送达」,记录可能丢失(崩溃、重载窗口)也可能重复(无游标的重新接管、SDK 重试),因此接收端对 ledger 记录基于 `(session.id, event.seq)` 去重;ops 记录刻意省略这类标识——它们是用于告警的信号,而非用于累加的条目,重复被容忍而非被去重。
## 共享披露
该 seam 的确认契约(归属 [Service Definition README 的共享披露段](../../packages/session/session-telemetry/README.md#the-sharing-disclosure)):每个后端都通过 `ctx.telemetry` 上必需的抽象 `sharing` 成员披露其部署级共享策略,消费方只有在未挂载任何遥测服务时才渲染「未配置」。披露只陈述当前策略,绝不承诺投递或留存——交接是非阻塞入队,批处理、重试与丢失策略仍归上报 SDK。
```ts type-equiv
/**
* Deployment-selected session-sharing policy disclosed by a mounted
* {@link Telemetry} backend to human-facing acknowledgement surfaces (the
* `/feedback` command's confirmation text). The seam owns the vocabulary so
* any backend can disclose a policy without depending on the OTel package;
* the values mirror the OTel backend's serialized `TelemetryMode` choices.
*/
type TelemetrySharingStatus = 'full' | 'feedback-only' | 'disabled'
```
## 后端约定
```ts type-equiv
@@ -104,7 +119,7 @@ interface TelemetryBackend {
}
```
`Telemetry``ctx.telemetry`[签名](#ctxtelemetry--telemetry-abstract-seam))是该约定的可加载类型:每个上下文只允许一个实现,重复加载会抛出异常后端在构造函数中创建 `TelemetryCoordinator`,以安装捕获处理
`Telemetry``ctx.telemetry`[签名](#ctxtelemetry--telemetry-abstract-seam))是该约定的可加载形态:每个上下文只允许一个实现,重复加载会抛出异常后端在构造函数中组合 seam 的 `TelemetryCoordinator`,以此装配捕获侧
## 脱敏 waterfall`telemetry/record`
@@ -141,7 +156,7 @@ flush?(): void
abstract shutdown(): Promise<void>
```
Source: [`packages/session/session-telemetry/src/index.ts:139`](../../packages/session/session-telemetry/src/index.ts)
Source: [`packages/session/session-telemetry/src/index.ts:148`](../../packages/session/session-telemetry/src/index.ts)
<a id="telemetry-events"></a>
+4
View File
@@ -341,6 +341,10 @@ const SCENARIOS: Scenario[] = [
// Windows bash process-tree kill is deferred with the Bash execution domain.
{ name: 'cancel-tool-calls', hasModelTurn: true, recorded: false, overridden: true, posixOnly: true },
{ name: 'subagent-spawn', hasModelTurn: true, recorded: true },
// Keyless authored scenario: the child ends at max-tokens with an empty
// usage-only assistant/message after earlier text and a tool call. The
// parent's tool result must retain that assistant output and stop reason.
{ name: 'subagent-max-tokens-partial', hasModelTurn: true, recorded: false },
{ name: 'subagent-multi', hasModelTurn: true, recorded: true },
{ name: 'subagent-fork', hasModelTurn: true, recorded: true },
{ name: 'subagent-mixed', hasModelTurn: true, recorded: true },
@@ -0,0 +1,14 @@
{
"steps": [
{
"op": "initialize"
},
{
"op": "newSession"
},
{
"op": "prompt",
"text": "Use the subagent tool exactly once to delegate this subtask: \"Write the words 'partial one', call todo_write once, then keep going until you are cut off.\" After the subagent returns, reply with the single word PARENT_DONE and stop."
}
]
}
@@ -0,0 +1,31 @@
{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":2,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1}
{"type":"approval/policy","seq":0,"time":1786373921809,"data":{"policy":"never","source":"delegation"}}
{"type":"agent/inbox/spliced","seq":1,"time":1786373921809,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Write the words 'partial one', call todo_write once, then keep going until you are cut off."}],"source":{"kind":"user"},"role":"user","id":"dbf0670a-79cc-4e2c-a298-c4d804e6fe61"}]}}
{"type":"turn/start","seq":2,"time":1786373921810,"data":{"turn":1}}
{"type":"agent/inbox/spliced","seq":3,"time":1786373921810,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"subagent/descriptor","seq":4,"time":1786373921831,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Truncated child"}}
{"type":"step/start","seq":5,"time":1786373921836,"data":{"turn":1,"step":1}}
{"type":"user/message","seq":6,"time":1786348800142,"data":{"content":[{"type":"text","text":"Write the words 'partial one', call todo_write once, then keep going until you are cut off."}],"source":{"kind":"user"},"role":"user","id":"dbf0670a-79cc-4e2c-a298-c4d804e6fe61"},"surfaceOp":"append"}
{"type":"user/message","seq":7,"time":1786373921837,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"885ea744-63dd-4198-95be-267b9db94a57"},"surfaceOp":"append"}
{"type":"session/title","seq":8,"time":1786373921837,"data":{"title":"Write the words 'partial one',","messageSeqs":[6],"source":{"kind":"fallback"}}}
{"type":"request/header","seq":9,"time":1786348800142,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/context","seq":10,"time":1786348800142,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","seq":12,"time":1786348800146,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"partial one"}}}}
{"type":"assistant/chunk","seq":13,"time":1786348800146,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":14,"time":1786348800146,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_child_1","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"keep going\", \"status\": \"in_progress\"}]}"}}}}
{"type":"assistant/chunk","seq":15,"time":1786348800146,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":9}}}}
{"type":"assistant/chunk","seq":16,"time":1786348800146,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":17,"time":1786348800146,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"partial one"},{"type":"tool-call","id":"call_child_1","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"keep going\", \"status\": \"in_progress\"}]}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5e4d07b2-6ce2-4ab6-8be0-fbdf2d3af138"},"usage":{"inputTokens":20,"outputTokens":9}},"sourceEventSeqs":[11,12,13,14,15,16],"surfaceOp":"append"}
{"type":"tool/call","seq":18,"time":1786348800146,"data":{"turn":1,"step":1,"callId":"call_child_1","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"keep going\", \"status\": \"in_progress\"}]}"}}
{"type":"todo/write","seq":19,"time":1786348800150,"data":{"todos":[{"content":"keep going","status":"in_progress"}]}}
{"type":"tool/result","seq":20,"time":1786348800151,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_child_1"},"content":[{"type":"tool-result","toolCallId":"call_child_1","content":[{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}],"isError":false}],"role":"user","id":"67efbbf3-ca1e-4d23-8f19-940cb391ff1e"}},"sourceEventSeqs":[18],"surfaceOp":"append"}
{"type":"step/end","seq":21,"time":1786348800151,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":22,"time":1786348800156,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":23,"time":1786348800160,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":24,"time":1786348800160,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_child_2","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"keep going\", \"status\": \"completed\"}]}"}}}}
{"type":"assistant/chunk","seq":25,"time":1786348800160,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":30,"outputTokens":4}}}}
{"type":"assistant/chunk","seq":26,"time":1786348800160,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"max-tokens"}}}}
{"type":"assistant/message","seq":27,"time":1786348800160,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"bb92e4ec-f260-4415-9782-b71147ea378d"},"usage":{"inputTokens":30,"outputTokens":4}},"sourceEventSeqs":[23,24,25,26],"surfaceOp":"append"}
{"type":"step/end","seq":28,"time":1786348800160,"data":{"turn":1,"step":2}}
{"type":"turn/end","seq":29,"time":1786348800160,"data":{"turn":1,"reason":{"kind":"max-tokens"}}}
@@ -0,0 +1,26 @@
{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1,"cwd":"{{cwd}}","delegationDepth":0}
{"type":"agent/inbox/spliced","seq":0,"time":1786348800078,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the subagent tool exactly once to delegate this subtask: \"Write the words 'partial one', call todo_write once, then keep going until you are cut off.\" After the subagent returns, reply with the single word PARENT_DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"8787ce07-4f1f-4368-bf58-18e30484ed44"}]}}
{"type":"turn/start","seq":1,"time":1786348800079,"data":{"turn":1}}
{"type":"agent/inbox/spliced","seq":2,"time":1786348800079,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","seq":3,"time":1786348800114,"data":{"turn":1,"step":1}}
{"type":"user/message","seq":4,"time":1786348800114,"data":{"content":[{"type":"text","text":"Use the subagent tool exactly once to delegate this subtask: \"Write the words 'partial one', call todo_write once, then keep going until you are cut off.\" After the subagent returns, reply with the single word PARENT_DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"8787ce07-4f1f-4368-bf58-18e30484ed44"},"surfaceOp":"append"}
{"type":"user/message","seq":5,"time":1786348800114,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"32a8b2ce-f1f9-411b-940d-c80f772561ac"},"surfaceOp":"append"}
{"type":"session/title","seq":6,"time":1786348800114,"data":{"title":"Use the subagent tool exactly","messageSeqs":[4],"source":{"kind":"fallback"}}}
{"type":"request/header","seq":7,"time":1786348800115,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/context","seq":8,"time":1786348800115,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
{"type":"assistant/chunk","seq":9,"time":1786348800120,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":10,"time":1786348800120,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_parent_1","name":"subagent","arguments":"{\"description\": \"Truncated child\", \"prompt\": \"Write the words 'partial one', call todo_write once, then keep going until you are cut off.\"}"}}}}
{"type":"assistant/chunk","seq":11,"time":1786348800120,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
{"type":"assistant/chunk","seq":12,"time":1786348800120,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":13,"time":1786348800120,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_parent_1","name":"subagent","arguments":"{\"description\": \"Truncated child\", \"prompt\": \"Write the words 'partial one', call todo_write once, then keep going until you are cut off.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"f4269cd2-9132-4b68-8f9b-ff3a40321bc9"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"}
{"type":"tool/call","seq":14,"time":1786348800121,"data":{"turn":1,"step":1,"callId":"call_parent_1","name":"subagent","arguments":"{\"description\": \"Truncated child\", \"prompt\": \"Write the words 'partial one', call todo_write once, then keep going until you are cut off.\"}"}}
{"type":"tool/result","seq":15,"time":1786348800163,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_parent_1"},"content":[{"type":"tool-result","toolCallId":"call_parent_1","content":[{"type":"text","text":"Error: subagent run hit its token limit before finishing\nPartial output before the run ended:\npartial one"}],"isError":true}],"role":"user","id":"5dd34050-a533-4f1b-99ee-5fc62c6a4502"}},"sourceEventSeqs":[14],"surfaceOp":"append"}
{"type":"step/end","seq":16,"time":1786348800163,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":17,"time":1786348800169,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":18,"time":1786348800173,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","seq":19,"time":1786348800173,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_DONE"}}}}
{"type":"assistant/chunk","seq":20,"time":1786348800173,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":12,"outputTokens":2}}}}
{"type":"assistant/chunk","seq":21,"time":1786348800173,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":22,"time":1786348800173,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"fb14560d-1d98-4b18-8736-b079de400315"},"usage":{"inputTokens":12,"outputTokens":2}},"sourceEventSeqs":[18,19,20,21],"surfaceOp":"append"}
{"type":"step/end","seq":23,"time":1786348800173,"data":{"turn":1,"step":2}}
{"type":"turn/end","seq":24,"time":1786348800173,"data":{"turn":1,"reason":{"kind":"completed"}}}
@@ -0,0 +1,4 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PARENT_DONE"}}}}
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}
+4
View File
@@ -6,6 +6,8 @@ pre-commit:
jobs:
- name: translation pairing (staged records)
glob: '*.i18n.yaml'
exclude:
- '.agents/notes/archived/**'
run: node_modules/.bin/tsx scripts/verify-translation-pairing.ts --cached {staged_files}
- name: lint (staged)
@@ -35,6 +37,8 @@ pre-merge-commit:
jobs:
- name: translation pairing (staged records)
glob: '*.i18n.yaml'
exclude:
- '.agents/notes/archived/**'
run: node_modules/.bin/tsx scripts/verify-translation-pairing.ts --cached {staged_files}
pre-push:
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent 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: 19d6e5ba7b554f59bd66e213f8a53389761fc735
README.zh.md: 17f58a2922e9019af054b0dccb6c4d9199fd1a9d
README.md: eb7df95bde10dafd7afcb168d30c9dda90296687
README.zh.md: 03cd02510267d3abdd414bed6ec1f42773d0811a
-1
View File
@@ -52,7 +52,6 @@ Groups hold `packages/<group>/<pkg>/`; names stay `@deepseek-ai/dsh-<pkg>`. **Gr
| [`boot/`](boot/README.md) | Shared app-bin boot glue | Product — stable surface |
| [`host/`](host/README.md) | Web-GUI host half: API gateway + HTTP route server | Product — stable surface |
| [`client/`](client/README.md) | Web-GUI browser half: shell, wire, object services, slots, `ui-*` plugins | Product — stable surface |
| [`experimental/`](experimental/README.md) | Prototypes and internal plugins | Unreleased |
| [`examples/`](examples/README.md) | Demo bundles (agent-spine + CLI/ACP/JSON-RPC bins) leaves load | Support — example infra |
| [`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<B>`, Harness home/path helpers, timeout, retention) | Support — small, stable, harness-dep-free |
-1
View File
@@ -52,7 +52,6 @@ npm scope 为 `@deepseek-ai/dsh-*`Cordis `Service` 子类和函数插件通
| [`boot/`](boot/README.md) | 共享的 app bin 启动粘合层 | 产品:稳定接口 |
| [`host/`](host/README.md) | web GUI 宿主半侧:API 网关 + HTTP 路由服务器 | 产品:稳定接口 |
| [`client/`](client/README.md) | web GUI 浏览器半侧:shell、协议层、对象服务、slot、`ui-*` 插件 | 产品:稳定接口 |
| [`experimental/`](experimental/README.md) | 原型和内部插件 | 未发布 |
| [`examples/`](examples/README.md) | 演示组合包(agent-spine + CLI/ACP/JSON-RPC bin),由叶节点加载 | 支持:示例基础设施 |
| [`support/`](support/README.md) | 支持基础设施(testkit、不变式、回放、Loader 冒烟测试) | 支持:兼容性预期较低 |
| [`util/`](util/README.md) | 组间共享的低层零依赖工具(`Branded<B>`、Harness home/路径辅助函数、超时、保留策略) | 支持:小型、稳定、无 harness 依赖 |
@@ -5,7 +5,7 @@
align-items: center;
gap: 4px;
max-width: 180px;
padding: 0 8px;
padding: 0 2px 0 0;
height: 22px;
border-radius: 6px;
background: var(--dsw-alias-fill-tsp-secondary);
@@ -11,7 +11,7 @@
import { useEffect } from 'react'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconAgentPresetOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
// Type-only: pulls the ui-conversation SlotMap merge (the header actions).
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { AgentPresetSettingsState } from './settings-store.ts'
@@ -57,7 +57,7 @@ export function AgentPresetLabel({
const text = option === undefined ? undefined : presetDisplayText(option, t)
return (
<span className={css.label} title={text?.description ?? t('headerHint')}>
<IconThinkOutline16 className={css.icon} />
<IconAgentPresetOutline16 size={14} className={css.icon} />
{text?.name ?? preset}
</span>
)
@@ -9,7 +9,7 @@
min-height: 28px;
padding: 0 8px;
border: none;
border-radius: 12px;
border-radius: 16px;
background: transparent;
color: var(--dsw-alias-label-primary);
font-size: 13px;
@@ -36,6 +36,61 @@
color: var(--dsw-alias-label-primary);
}
/* Introduce cue: the icon eases in on an overshoot-free expo curve (duration
matches INTRO_TEXT_DELAY_MS, so the characters start the moment it lands),
then the name's characters fade up on a stagger (delays set inline per
character). All chars occupy their width from the start, so nothing
reflows mid-run. */
.introIcon {
animation: seat-icon-in 0.15s cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes seat-icon-in {
from {
opacity: 0;
transform: scale(0.5);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* Wraps the staggered characters into one flex item, so the chip's gap
applies around the name as a whole rather than between characters. */
.introText {
display: inline-block;
white-space: pre;
}
.introChar {
display: inline-block;
white-space: pre;
opacity: 0;
animation: seat-char-in 0.4s ease-out forwards;
}
@keyframes seat-char-in {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: none;
}
}
@media (prefers-reduced-motion: reduce) {
.introIcon,
.introChar {
animation: none;
opacity: 1;
}
}
.chevron {
flex: none;
color: var(--dsw-alias-label-caption);
@@ -15,7 +15,7 @@
import { useEffect, useState } from 'react'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { IconChevronDownOutline14, IconThinkOutline16, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconAgentPresetOutline16, IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
// Type-only: pulls the ui-conversation SlotMap merge (the hero seat).
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { AgentPresetSeatState } from './seat-store.ts'
@@ -32,6 +32,29 @@ export interface AgentPresetSeatInjected {
load: () => Promise<void>
/** Stage one preset for the next session. */
select: (id: string) => Promise<void>
/** Clear the one-shot introduce cue once the chip has played it. */
introduced: () => void
}
/* Introduce timeline: the icon eases in first (the CSS animation shares this
duration); the name's characters start fading up the moment it lands, each
taking the fade duration to settle. The cue clears after the last one. The
stagger is capped twice: per tick for short CJK names, and by one shared
reveal window so a long Latin name finishes in the same time as its CJK
counterpart instead of dragging the run out per character. */
const INTRO_TEXT_DELAY_MS = 150
const INTRO_CHAR_STAGGER_MS = 40
const INTRO_TEXT_REVEAL_MS = 200
const INTRO_CHAR_FADE_MS = 400
/**
* Per-character start offset for the introduce reveal.
* @param count - character count of the shown preset name.
* @returns milliseconds between successive character starts.
*/
function introStaggerMs(count: number): number {
if (count <= 1) return 0
return Math.min(INTRO_CHAR_STAGGER_MS, INTRO_TEXT_REVEAL_MS / (count - 1))
}
/** Full component props. */
@@ -45,7 +68,7 @@ export type AgentPresetSeatProps =
* @param props - composed slot props.
* @returns the chip, or null when the deployment composes no presets.
*/
export function AgentPresetSeat({ load, select, useAgentPresetSeat, t }: AgentPresetSeatProps) {
export function AgentPresetSeat({ load, select, introduced, useAgentPresetSeat, t }: AgentPresetSeatProps) {
const state = useAgentPresetSeat(snapshot => snapshot)
const [open, setOpen] = useState(false)
@@ -53,12 +76,54 @@ export function AgentPresetSeat({ load, select, useAgentPresetSeat, t }: AgentPr
void load()
}, [load])
// Nothing to choose between: the deployment composes no presets and every
// session shares the host composition.
if (state.options.length === 0 || state.current === '') return null
const chosen = state.options.find(option => option.id === state.current)
const chosenText = chosen === undefined ? undefined : presetDisplayText(chosen, t)
const label = chosenText?.name ?? state.current
const ready = state.options.length > 0 && state.current !== ''
// The introduce cue: the pick was staged from another screen (the settings
// creator entry), so the chip announces it — the icon eases in and each
// character of the name fades up on a stagger (CSS owns the motion; this
// effect only arms it and acknowledges the cue once the run is over).
const [introducing, setIntroducing] = useState(false)
useEffect(() => {
if (!state.introduce || !ready) return
const characters = Array.from(label)
if (characters.length === 0 || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
introduced()
return
}
setIntroducing(true)
const done = window.setTimeout(() => {
setIntroducing(false)
introduced()
}, INTRO_TEXT_DELAY_MS + (characters.length - 1) * introStaggerMs(characters.length) + INTRO_CHAR_FADE_MS)
return () => { window.clearTimeout(done) }
}, [state.introduce, ready, label, introduced])
// Nothing to choose between: the deployment composes no presets and every
// session shares the host composition.
if (!ready) return null
// One wrapper span: the chip is a flex row with a gap, so loose character
// spans would each pick up the gap between them.
const characters = Array.from(label)
const stagger = introStaggerMs(characters.length)
const shownLabel = introducing
? (
<span className={css.introText}>
{characters.map((character, index) => (
<span
key={index}
className={css.introChar}
style={{ animationDelay: `${INTRO_TEXT_DELAY_MS + index * stagger}ms` }}
>
{character}
</span>
))}
</span>
)
: label
return (
<Menu
@@ -95,8 +160,8 @@ export function AgentPresetSeat({ load, select, useAgentPresetSeat, t }: AgentPr
disabled={state.busy}
onClick={() => { setOpen(value => !value) }}
>
<IconThinkOutline16 className={css.seatIcon} />
{chosenText?.name ?? state.current}
<IconAgentPresetOutline16 className={introducing ? `${css.seatIcon} ${css.introIcon}` : css.seatIcon} />
{shownLabel}
<IconChevronDownOutline14 className={css.chevron} />
</button>
)}
@@ -26,6 +26,12 @@
gap: 10px;
}
/* Group-to-group breathing room: the section's 12px gap plus 20px reads the
two rosters as separate blocks (32px total). */
.group + .group {
margin-top: 20px;
}
.groupHead {
margin: 0;
font-size: 12px;
@@ -363,6 +369,7 @@
create button vacated. Dashed like the Models page's add affordances: it
reads as a place a preset will appear, not a command. */
.creatorButton {
box-sizing: border-box;
align-self: stretch;
display: flex;
align-items: center;
@@ -372,17 +379,18 @@
border: 1px dashed var(--dsw-alias-border-l3);
border-radius: 12px;
font: inherit;
font-size: 13px;
background: none;
color: inherit;
font-size: 14px;
line-height: 22px;
background: transparent;
color: var(--dsw-alias-label-primary);
cursor: pointer;
}
.creatorButton:hover:not(:disabled) {
background: var(--dsw-alias-bg-layer-1);
background: var(--dsw-alias-interactive-bg-hover);
}
.creatorButton:disabled {
opacity: 0.5;
opacity: 0.4;
cursor: default;
}
@@ -171,6 +171,30 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
)
}
/* The guided alternative to copying: the self-referential preset can
read this very composition and author a new one in conversation.
Offered only where that preset is actually on the roster and a
session can be landed; without a writable root the draft could
never be discovered, so the reason rides the disabled button. */
const creatorButton = props.startCreatorDraft !== undefined && state.rows.some(row => row.id === 'cordis')
? (
<button
type="button"
className={css.creatorButton}
disabled={!state.authorable}
title={state.authorable ? undefined : t('duplicateUnavailable')}
onClick={() => {
props.startCreatorDraft?.()
props.close()
}}
>
{/* Same glyph as the Models page's add affordances. */}
<IconPlusOutline16 size={14} />
{t('creatorDraft')}
</button>
)
: null
return (
<div className={css.section}>
<h2 className={css.title}>{t('nav')}</h2>
@@ -180,147 +204,130 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
const group = state.rows
.filter(row => row.trust === trust)
.map(row => ({ row, text: presetDisplayText(row, t) }))
if (group.length === 0) return null
// The custom group is where a preset of one's own will appear, so it
// stays on screen even while empty: heading plus the creator entry.
const tail = trust === 'user' ? creatorButton : null
if (group.length === 0 && tail === null) return null
return (
<section key={trust} className={css.group}>
<h3 className={css.groupHead}>{heading}</h3>
<ul className={css.cards}>
{group.map(({ row, text }) => (
<li
key={row.id}
className={row.broken !== undefined
? `${css.card} ${css.cardBroken}`
: row.isDefault ? `${css.card} ${css.cardActive}` : css.card}
>
{/* The card body IS the control: picking a preset is the
{group.length === 0 ? null : (
<ul className={css.cards}>
{group.map(({ row, text }) => (
<li
key={row.id}
className={row.broken !== undefined
? `${css.card} ${css.cardBroken}`
: row.isDefault ? `${css.card} ${css.cardActive}` : css.card}
>
{/* The card body IS the control: picking a preset is the
common act, so it should not hide behind a small button.
The action row sits outside it — nesting buttons is
invalid, and these act on the card rather than select it.
A broken preset cannot compose a session, so its body is
disabled and the card says why instead of offering it. */}
<button
type="button"
className={css.cardMain}
aria-pressed={row.isDefault}
disabled={row.isDefault || row.broken !== undefined}
// Without this the name is the whole card read aloud —
// title, badge, description, id.
aria-label={`${row.broken !== undefined ? t('brokenBadge') : row.isDefault ? t('inUse') : t('setDefault')}: ${text.name}`}
title={row.broken ?? (row.isDefault ? t('inUse') : t('setDefault'))}
onClick={() => { void props.makeDefault(row.id) }}
>
<span className={css.cardHead}>
<span className={css.cardName}>{text.name}</span>
{row.broken !== undefined
? <span className={css.brokenBadge}>{t('brokenBadge')}</span>
: null}
<span className={css.badge}>
{row.trust === 'user' ? t('userTrust') : t('builtIn')}
<button
type="button"
className={css.cardMain}
aria-pressed={row.isDefault}
disabled={row.isDefault || row.broken !== undefined}
// Without this the name is the whole card read aloud —
// title, badge, description, id.
aria-label={`${row.broken !== undefined ? t('brokenBadge') : row.isDefault ? t('inUse') : t('setDefault')}: ${text.name}`}
title={row.broken ?? (row.isDefault ? t('inUse') : t('setDefault'))}
onClick={() => { void props.makeDefault(row.id) }}
>
<span className={css.cardHead}>
<span className={css.cardName}>{text.name}</span>
{row.broken !== undefined
? <span className={css.brokenBadge}>{t('brokenBadge')}</span>
: null}
<span className={css.badge}>
{row.trust === 'user' ? t('userTrust') : t('builtIn')}
</span>
{row.isDefault ? <span className={css.inUse}>{t('inUse')}</span> : null}
</span>
{row.isDefault ? <span className={css.inUse}>{t('inUse')}</span> : null}
</span>
<span className={css.cardDesc}>{text.description ?? t('noDescription')}</span>
{row.broken === undefined
? null
: <span className={css.cardBrokenReason} role="alert">{row.broken}</span>}
<code className={css.cardId}>{row.id}</code>
</button>
<div className={css.cardFoot}>
{/* Shipped presets are the compositions a copy starts
<span className={css.cardDesc}>{text.description ?? t('noDescription')}</span>
{row.broken === undefined
? null
: <span className={css.cardBrokenReason} role="alert">{row.broken}</span>}
<code className={css.cardId}>{row.id}</code>
</button>
<div className={css.cardFoot}>
{/* Shipped presets are the compositions a copy starts
from, so READING one is the point; a custom preset is
edited in its files instead, which the location action
leads to. A broken shipped preset has no readable
composition to offer, so its viewer is withheld; a
broken custom one keeps the location action — the
files are where it gets fixed. */}
{row.trust === 'system'
? row.broken === undefined
? (
{row.trust === 'system'
? row.broken === undefined
? (
<button
type="button"
className={css.iconButton}
data-tip={t('view')}
aria-label={`${t('view')}: ${text.name}`}
onClick={() => { void props.view(row.id) }}
>
<IconBrowseOutline16 />
</button>
)
: null
: (
<button
type="button"
className={css.iconButton}
data-tip={t('view')}
aria-label={`${t('view')}: ${text.name}`}
onClick={() => { void props.view(row.id) }}
data-tip={state.hasDocument ? t('openLocation') : t('showLocation')}
aria-label={`${state.hasDocument ? t('openLocation') : t('showLocation')}: ${text.name}`}
onClick={() => { void props.openLocation(row.id) }}
>
<IconBrowseOutline16 />
<IconFolderOpenOutline16 />
</button>
)}
<button
type="button"
className={css.iconButton}
disabled={!state.authorable || row.broken !== undefined}
data-tip={row.broken !== undefined
? t('brokenNoCopy')
: state.authorable ? t('duplicate') : t('duplicateUnavailable')}
aria-label={`${t('duplicate')}: ${text.name}`}
onClick={() => { props.beginCopy(row.id) }}
>
<IconCopyOutline16 />
</button>
{row.trust === 'user'
? (
<button
type="button"
className={`${css.iconButton} ${css.iconDanger}`}
data-tip={t('delete')}
aria-label={`${t('delete')}: ${text.name}`}
onClick={() => { props.confirmDelete(row.id) }}
>
<IconTrashOutline16 />
</button>
)
: null
: null}
</div>
{state.revealedPaths[row.id] === undefined
? null
: (
<button
type="button"
className={css.iconButton}
data-tip={state.hasDocument ? t('openLocation') : t('showLocation')}
aria-label={`${state.hasDocument ? t('openLocation') : t('showLocation')}: ${text.name}`}
onClick={() => { void props.openLocation(row.id) }}
>
<IconFolderOpenOutline16 />
</button>
<p className={css.revealedPath}>
<span className={css.revealedPathLabel}>{t('revealedPathLabel')}</span>
<code>{state.revealedPaths[row.id]}</code>
</p>
)}
<button
type="button"
className={css.iconButton}
disabled={!state.authorable || row.broken !== undefined}
data-tip={row.broken !== undefined
? t('brokenNoCopy')
: state.authorable ? t('duplicate') : t('duplicateUnavailable')}
aria-label={`${t('duplicate')}: ${text.name}`}
onClick={() => { props.beginCopy(row.id) }}
>
<IconCopyOutline16 />
</button>
{row.trust === 'user'
? (
<button
type="button"
className={`${css.iconButton} ${css.iconDanger}`}
data-tip={t('delete')}
aria-label={`${t('delete')}: ${text.name}`}
onClick={() => { props.confirmDelete(row.id) }}
>
<IconTrashOutline16 />
</button>
)
: null}
</div>
{state.revealedPaths[row.id] === undefined
? null
: (
<p className={css.revealedPath}>
<span className={css.revealedPathLabel}>{t('revealedPathLabel')}</span>
<code>{state.revealedPaths[row.id]}</code>
</p>
)}
</li>
))}
</ul>
</li>
))}
</ul>
)}
{tail}
</section>
)
})}
{/* The guided alternative to copying: the self-referential preset can
read this very composition and author a new one in conversation.
Offered only where that preset is actually on the roster and a
session can be landed; without a writable root the draft could
never be discovered, so the reason rides the disabled button. */}
{props.startCreatorDraft !== undefined && state.rows.some(row => row.id === 'cordis')
? (
<button
type="button"
className={css.creatorButton}
disabled={!state.authorable}
title={state.authorable ? undefined : t('duplicateUnavailable')}
onClick={() => {
props.startCreatorDraft?.()
props.close()
}}
>
{/* Same glyph as the Models page's add affordances. */}
<IconPlusOutline16 size={14} />
{t('creatorDraft')}
</button>
)
: null}
<CopyDialog
state={state}
t={t}
@@ -114,6 +114,7 @@ export function apply(ctx: ClientContext): void {
hooks: { agentPresetSeat: seat.store },
load: () => seat.load(),
select: (id: string) => seat.select(id),
introduced: () => { seat.introduced() },
})
const labelInjected = (): AgentPresetLabelInjected => ({
@@ -146,7 +147,9 @@ export function apply(ctx: ClientContext): void {
// on: the chip's list-change applier composes the blank session the
// workspace connect produces or reuses.
creatorDraft = () => {
seat.stage('cordis')
// The introduce cue makes the chip announce the pick the user never
// made on this screen — the stage happened back in settings.
seat.stage('cordis', true)
scope.workspaces.startSession()
}
const chip = scope.slots.register({
@@ -26,10 +26,16 @@ export interface AgentPresetSeatState {
/** A rejected apply's message, cleared by the next attempt. */
error: string | null
busy: boolean
/**
* One-shot cue that the chip should introduce itself (the creator-draft
* entry staged the pick from another screen, so the user never touched the
* chip); the renderer clears it via `introduced()` once played.
*/
introduce: boolean
}
const INITIAL: AgentPresetSeatState = {
options: [], current: '', error: null, busy: false,
options: [], current: '', error: null, busy: false, introduce: false,
}
/** One session's identity and whether it has started. */
@@ -121,10 +127,18 @@ export class AgentPresetSeatController {
* list-change applier, which fires when the started session becomes
* current.
* @param id - the preset to stage.
* @param introduce - true when the stage came from another screen and the
* chip should announce itself on the session it lands on.
*/
stage(id: string): void {
stage(id: string, introduce = false): void {
this.staged = id
this.set({ current: id, error: null })
this.set({ current: id, error: null, introduce })
}
/** Acknowledge the introduction cue once the chip has played it. */
introduced(): void {
if (!this.store.getSnapshot().introduce) return
this.set({ introduce: false })
}
/**
@@ -496,6 +496,15 @@ describe('ui-agent-preset apply', () => {
expect(section.startCreatorDraft).toBeDefined()
expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis')
expect(workspaces.starts).toHaveLength(1)
// A cross-screen stage carries the introduce cue; the chip acknowledges
// it once, and a repeat acknowledgement leaves the snapshot untouched.
expect(seat.hooks.agentPresetSeat.getSnapshot().introduce).toBe(true)
seat.introduced()
const acknowledged = seat.hooks.agentPresetSeat.getSnapshot()
expect(acknowledged.introduce).toBe(false)
seat.introduced()
expect(seat.hooks.agentPresetSeat.getSnapshot()).toBe(acknowledged)
conversation()
})
@@ -41,6 +41,7 @@ const SEAT_READY: AgentPresetSeatState = {
],
busy: false,
error: null,
introduce: false,
}
function renderRow(state: Partial<AgentPresetSettingsState> = {}) {
@@ -56,7 +57,11 @@ function renderRow(state: Partial<AgentPresetSettingsState> = {}) {
function renderSeat(state: Partial<AgentPresetSeatState> = {}) {
const store = createSnapshotStore<AgentPresetSeatState>({ ...SEAT_READY, ...state })
const actions = { load: vi.fn(() => Promise.resolve()), select: vi.fn(() => Promise.resolve()) }
const actions = {
load: vi.fn(() => Promise.resolve()),
select: vi.fn(() => Promise.resolve()),
introduced: vi.fn(),
}
render(<AgentPresetSeat {...({
...actions,
useAgentPresetSeat: bindSnapshotSelector(store),
@@ -272,6 +277,94 @@ describe('the new-session chip', () => {
})
})
describe('the chip introduce cue', () => {
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
/** Character spans carry inline animation delays; nothing else does. */
function delayedChars(): HTMLElement[] {
return Array.from(screen.getByRole('button').querySelectorAll<HTMLElement>('[style]'))
}
it('reveals a long Latin name inside the shared window, then acknowledges', () => {
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false })))
vi.useFakeTimers()
const actions = renderSeat({
current: 'creator',
options: [{ id: 'creator', trust: 'user', name: 'CreatorMode' }],
introduce: true,
})
// Eleven characters split the 200ms window into 20ms steps, where the
// fixed 40ms tick would have doubled the run for a Latin name.
const chars = delayedChars()
expect(chars.map(span => span.textContent).join('')).toBe('CreatorMode')
expect(chars[0]!.style.animationDelay).toBe('150ms')
expect(chars[1]!.style.animationDelay).toBe('170ms')
expect(chars[10]!.style.animationDelay).toBe('350ms')
// 150 delay + 200 window + 400 fade: acknowledged only once the last
// character has settled, and the label is plain text again after.
act(() => { vi.advanceTimersByTime(749) })
expect(actions.introduced).not.toHaveBeenCalled()
act(() => { vi.advanceTimersByTime(1) })
expect(actions.introduced).toHaveBeenCalledTimes(1)
expect(delayedChars()).toHaveLength(0)
})
it('keeps the per-tick cap for a short CJK name', () => {
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false })))
vi.useFakeTimers()
renderSeat({
current: 'creator',
options: [{ id: 'creator', trust: 'user', name: '创造模式' }],
introduce: true,
})
// Four characters fit under the window, so the 40ms tick applies as-is.
const chars = delayedChars()
expect(chars).toHaveLength(4)
expect(chars[1]!.style.animationDelay).toBe('190ms')
expect(chars[3]!.style.animationDelay).toBe('270ms')
})
it('starts a one-character name with no stagger at all', () => {
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false })))
vi.useFakeTimers()
const actions = renderSeat({
current: 'creator',
options: [{ id: 'creator', trust: 'user', name: 'C' }],
introduce: true,
})
expect(delayedChars()[0]!.style.animationDelay).toBe('150ms')
act(() => { vi.advanceTimersByTime(550) })
expect(actions.introduced).toHaveBeenCalledTimes(1)
})
it('skips the run under reduced motion and acknowledges at once', () => {
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true })))
const actions = renderSeat({ introduce: true })
expect(actions.introduced).toHaveBeenCalledTimes(1)
expect(delayedChars()).toHaveLength(0)
})
it('acknowledges an empty staged name without arming a run', () => {
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false })))
const actions = renderSeat({
current: 'creator',
options: [{ id: 'creator', trust: 'user', name: '' }],
introduce: true,
})
expect(actions.introduced).toHaveBeenCalledTimes(1)
expect(delayedChars()).toHaveLength(0)
})
})
describe('the session-header label', () => {
it('names the preset the session runs, and never offers a switch', async () => {
const { load } = renderLabel({ blank: false, agentPreset: 'standard' })
@@ -253,6 +253,20 @@ describe('the preset list', () => {
expect(actions.close).toHaveBeenCalledTimes(1)
})
it('keeps the empty custom group on screen: heading plus the creator entry', () => {
renderSection({
rows: [
{ id: 'standard', trust: 'system', isDefault: true, name: '标准模式' },
{ id: 'cordis', trust: 'system', isDefault: false, name: '创造模式' },
],
})
// No member yet, but the place where one's own preset will appear stays.
expect(screen.getByRole('heading', { name: en.customGroup })).toBeTruthy()
expect(screen.getByRole('button', { name: en.creatorDraft })).toBeTruthy()
expect(screen.queryByText(`· ${en.userTrust}`)).toBeNull()
})
it('hides the creator entry without the flow or the preset, disables it without a root', () => {
renderSection()
expect(screen.queryByRole('button', { name: en.creatorDraft })).toBeNull()
@@ -60,7 +60,7 @@ export const zh = {
'access.confirm.acknowledge': '我已了解风险,并愿意继续',
'access.confirm.cancel': '取消',
'access.confirm.enable': '启用 Full access',
'hero.headline': '探索未之境',
'hero.headline': '探索未之境',
'hero.preview': '预览版',
'hero.chooseWorkspace': '选择工作区',
'session.hierarchy': '会话层级',
@@ -263,8 +263,9 @@
.composerHero {
position: relative; /* .heroGlow positioning context */
align-self: center;
/* figma 75:8208: 12 between hero chrome / workspace row / card. */
gap: 12px;
/* figma 75:8208 drew 12 between all three rows; the workspace row now sits
8 above the card (its margin-top restores 12 under the hero chrome). */
gap: 8px;
/* Foot inside the centered box floats the stack a bit above true center. */
padding-bottom: 32px;
/* Card cap + both clearances: the hero input card lands at exactly the same
@@ -292,7 +293,9 @@
.heroWorkspaceRow {
display: flex;
align-items: center;
gap: 2px;
min-width: 0;
margin-top: 4px;
/* figma drew px 8; nudged +12 so the chip's folder glyph lines up closer to
the card's inner controls below. */
padding-left: 20px;
@@ -105,7 +105,7 @@
min-height: 28px;
padding: 0 8px;
border: none;
border-radius: 12px;
border-radius: 16px;
background: transparent;
color: var(--dsw-alias-label-primary);
font-size: 13px;
@@ -51,6 +51,9 @@
}
.chevron {
/* inline-flex, not inline: an inline seat reserves baseline descent under
the svg and floats the glyph off-center in the 28px trigger. */
display: inline-flex;
flex: 0 0 auto;
color: var(--dsw-alias-label-caption);
transition: transform 120ms ease;
@@ -358,7 +358,7 @@ describe('ConversationRoot resident composer', () => {
const header = b.view.container.querySelector('header')
expect(host).not.toBeNull()
expect(header?.getAttribute('aria-hidden')).toBe('true')
expect(b.view.getByText('探索未之境')).toBeTruthy()
expect(b.view.getByText('探索未之境')).toBeTruthy()
expect(b.view.getByText('预览版')).toBeTruthy()
expect(b.view.queryByTestId('view-chat')).toBeNull()
// The same machine-backed textarea is live in the hero, and the
@@ -382,7 +382,7 @@ describe('ConversationRoot resident composer', () => {
const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true, openState: 'loading' }))
const root = b.view.container.querySelector('[data-phase]')
expect(root?.getAttribute('data-phase')).toBe('settling')
expect(b.view.queryByText('探索未之境')).toBeNull()
expect(b.view.queryByText('探索未之境')).toBeNull()
})
it('settling phase: a session the list has no row for settles conservatively', () => {
@@ -407,7 +407,7 @@ describe('ConversationRoot resident composer', () => {
// blank the column for the history round-trip.
const root = b.view.container.querySelector('[data-phase]')
expect(root?.getAttribute('data-phase')).toBe('hero')
expect(b.view.getByText('探索未之境')).toBeTruthy()
expect(b.view.getByText('探索未之境')).toBeTruthy()
expect(b.view.getByRole('textbox')).toBeTruthy()
})
@@ -425,7 +425,7 @@ describe('ConversationRoot resident composer', () => {
expect(after.value).toBe('kept across flip')
expect(b.chat.store.getSnapshot().draft).toBe('kept across flip')
expect(b.view.container.querySelector('[data-conversation-scroll]')?.contains(after)).toBe(true)
expect(b.view.queryByText('探索未之境')).toBeNull()
expect(b.view.queryByText('探索未之境')).toBeNull()
expect(b.view.getByTestId('view-chat')).toBeTruthy()
})
@@ -349,6 +349,35 @@ export const IconThinkOutline16 = ({ size = 16, className }: IconProps) => (
</svg>
)
/** ic_ds_agent_preset_outline_16 (figma extract): node interiors knock out to transparency via mask, so the glyph sits on any fill. */
export const IconAgentPresetOutline16 = ({ 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">
<mask id="mask0_agent_preset_16" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="16">
<rect width="16" height="16" fill="white" />
<circle cx="7.9995" cy="3.28319" r="1.712" fill="black" />
<circle cx="3.51122" cy="11.3855" r="1.712" fill="black" />
<circle cx="12.4878" cy="11.3855" r="1.712" fill="black" />
</mask>
<path
mask="url(#mask0_agent_preset_16)"
d="M12.2881 11.0425C12.6002 11.3723 13.0413 11.5786 13.5312 11.5786L13.5342 11.5776C13.1476 12.3233 12.6119 12.9785 11.9639 13.5005C10.9327 14.3309 9.6199 14.8286 8.19336 14.8286C7.29864 14.8285 6.45056 14.6313 5.6875 14.2808C6.08309 14.0281 6.36707 13.6189 6.45215 13.1392C6.99022 13.3561 7.57767 13.476 8.19336 13.4761C9.30019 13.4761 10.3157 13.0915 11.1152 12.4478C11.5935 12.0626 11.9924 11.5848 12.2881 11.0425ZM4.14746 4.36475C4.25569 4.83228 4.55488 5.2247 4.95898 5.4585C4.07956 6.30639 3.53144 7.49605 3.53125 8.81396C3.53125 9.69534 3.77613 10.5202 4.20117 11.2231C3.74959 11.3817 3.38395 11.7232 3.19531 12.1597C2.5541 11.2032 2.17969 10.052 2.17969 8.81396C2.17989 7.05087 2.93868 5.4646 4.14746 4.36475ZM8.19336 2.80029C8.85717 2.80029 9.49784 2.90834 10.0967 3.10791C12.3237 3.85044 13.9725 5.86061 14.1846 8.28369C13.9832 8.20048 13.7627 8.15382 13.5312 8.15381C13.2802 8.15381 13.042 8.20907 12.8271 8.30615C12.6281 6.47264 11.3666 4.95616 9.66895 4.39014C9.2063 4.236 8.70989 4.15186 8.19336 4.15186C7.96112 4.15189 7.7329 4.16981 7.50977 4.20264C7.51947 4.12886 7.52637 4.05348 7.52637 3.97705C7.52628 3.56604 7.3811 3.18914 7.13965 2.89404C7.48183 2.83352 7.83381 2.80033 8.19336 2.80029Z"
fill="currentColor"
/>
<path
d="M9.1123 3.28271C9.11205 2.66858 8.61322 2.17041 7.99902 2.17041C7.38504 2.17067 6.88697 2.66874 6.88672 3.28271C6.88672 3.89691 7.38489 4.39574 7.99902 4.396C8.61338 4.396 9.1123 3.89707 9.1123 3.28271ZM10.3115 3.28271C10.3115 4.55981 9.27612 5.59521 7.99902 5.59521C6.72214 5.59496 5.6875 4.55965 5.6875 3.28271C5.68776 2.00599 6.7223 0.971447 7.99902 0.971191C9.27596 0.971191 10.3113 2.00584 10.3115 3.28271Z"
fill="currentColor"
/>
<path
d="M4.62402 11.385C4.62377 10.7709 4.12494 10.2727 3.51074 10.2727C2.89676 10.273 2.39869 10.771 2.39844 11.385C2.39844 11.9992 2.89661 12.498 3.51074 12.4983C4.1251 12.4983 4.62402 11.9994 4.62402 11.385ZM5.82324 11.385C5.82324 12.6621 4.78784 13.6975 3.51074 13.6975C2.23386 13.6973 1.19922 12.6619 1.19922 11.385C1.19947 10.1083 2.23402 9.07374 3.51074 9.07349C4.78768 9.07349 5.82299 10.1081 5.82324 11.385Z"
fill="currentColor"
/>
<path
d="M13.6006 11.385C13.6003 10.7709 13.1015 10.2727 12.4873 10.2727C11.8733 10.273 11.3753 10.771 11.375 11.385C11.375 11.9992 11.8732 12.498 12.4873 12.4983C13.1017 12.4983 13.6006 11.9994 13.6006 11.385ZM14.7998 11.385C14.7998 12.6621 13.7644 13.6975 12.4873 13.6975C11.2104 13.6973 10.1758 12.6619 10.1758 11.385C10.176 10.1083 11.2106 9.07374 12.4873 9.07349C13.7642 9.07349 14.7995 10.1081 14.7998 11.385Z"
fill="currentColor"
/>
</svg>
)
/** ic_ds_browse_outline_16 */
export const IconBrowseOutline16 = ({ 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">
@@ -16,8 +16,8 @@ const icons = Object.fromEntries(
const iconNames = Object.keys(icons)
describe('ic_ds_ icon set', () => {
it('exports the full icon set (46 deepsuite + 18 figma extracts + three product glyphs outside those sets)', () => {
expect(iconNames.length).toBe(67)
it('exports the full icon set (46 deepsuite + 19 figma extracts + three product glyphs outside those sets)', () => {
expect(iconNames.length).toBe(68)
})
it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => {
@@ -205,11 +205,11 @@
background: var(--dsw-alias-interactive-bg-hover);
}
/* Options area (figma Options 501:29983): pad (24,0,24,8), scrolls. */
/* Options area (figma Options 501:29983): pad (24,0,24,24), scrolls. */
.options {
flex: 1;
min-height: 0;
padding: 0 24px 8px;
padding: 0 24px 24px;
overflow-y: auto;
}
@@ -14,8 +14,8 @@
import { useCallback, useEffect, useId, useRef, useState } from 'react'
import clsx from 'clsx'
import {
IconCloseOutline16, IconDataOutline16, IconPersonalizationOutline16,
IconSettingsOutline16, IconThinkOutline16,
IconAgentPresetOutline16, IconCloseOutline16, IconDataOutline16,
IconPersonalizationOutline16, IconSettingsOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SettingsRootComponentProps, SettingsSectionRow } from './contract/slots.ts'
import css from './SettingsRoot.module.css'
@@ -23,7 +23,7 @@ import css from './SettingsRoot.module.css'
/** Nav glyph by section id; unknown ids fall back to the settings gear. */
function navIcon(id: string) {
if (id === 'models') return <IconDataOutline16 className={css.navIcon} size={16} />
if (id === 'agent-presets') return <IconThinkOutline16 className={css.navIcon} size={16} />
if (id === 'agent-presets') return <IconAgentPresetOutline16 className={css.navIcon} size={16} />
if (id === 'plugins') return <IconPersonalizationOutline16 className={css.navIcon} size={16} />
return <IconSettingsOutline16 className={css.navIcon} size={16} />
}
@@ -84,7 +84,7 @@
gap: 8px;
height: 60px;
padding: 8px 0 8px 4px;
margin-bottom: 16px;
margin-bottom: 8px;
box-sizing: border-box;
overflow: hidden;
}
@@ -157,8 +157,8 @@
color: var(--dsw-alias-label-primary);
}
/* New Session: 38px capsule (figma 133:7634); collapsed it renders as the
rail's plain icon control. */
/* New Session: 38px bar, 12px radius (figma 133:7634 geometry, squared-off
corners); collapsed it renders as the rail's plain icon control. */
.newSession {
flex: none;
display: flex;
@@ -170,7 +170,7 @@
margin: 0 2px 20px; /* bottom: former headerBlock padBottom 12 + root gap 8 */
box-sizing: border-box;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 24px;
border-radius: 12px;
background: var(--dsw-alias-button-elevated-fill);
color: var(--dsw-alias-label-primary);
font-size: 14px;
@@ -54,7 +54,6 @@
max-height: min(560px, calc(100vh - 140px));
padding: 4px;
overflow: auto;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 12px;
background: var(--dsw-specific-menu);
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
@@ -519,8 +519,14 @@ export function SubagentCatalogAction({
observedCatalogs.current.clear()
}, [])
// Visibility needs evidence of children (entries, summary-known descendants,
// or a failed load worth retrying). A bare loading catalog is not evidence:
// selecting any session schedules a refresh whose loading snapshot would
// otherwise flash the action in and out on childless sessions.
const visible = presentedCatalog !== undefined
&& (presentedCatalog.state !== 'ready' || presentedCatalog.entries.length > 0)
&& (presentedCatalog.state === 'error'
|| presentedCatalog.entries.length > 0
|| descendantCount > 0)
useEffect(() => {
if (visible || !open) return
setOpen(false)
@@ -522,22 +522,23 @@ describe('SubagentCatalogAction', () => {
expect(staleEmpty.openChild).not.toHaveBeenCalled()
})
it('renders empty loading and fallback error states without focusable rows', async () => {
it('hides a bare loading catalog and keeps the error fallback without focusable rows', async () => {
// Selecting any session schedules a catalog refresh; a loading snapshot
// with no other evidence of children must not flash the action in.
const loading = props(catalog({ entries: [], state: 'loading' }))
const view = render(<SubagentCatalogAction {...loading} />)
const trigger = screen.getByRole('button', { name: /0 个子代理/ })
fireEvent.click(trigger)
expect(screen.getByText('正在加载子代理…')).toBeTruthy()
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
await Promise.resolve()
expect(screen.getByRole('tree')).toBeTruthy()
fireEvent.keyDown(screen.getByRole('tree'), { key: 'ArrowUp' })
expect(screen.queryByRole('button')).toBeNull()
view.unmount()
const failed = props(catalog({ entries: [], state: 'error', error: null }))
render(<SubagentCatalogAction {...failed} />)
fireEvent.click(screen.getByRole('button', { name: /0 个子代理/ }))
const trigger = screen.getByRole('button', { name: /0 个子代理/ })
fireEvent.click(trigger)
expect(screen.getByText('无法加载子代理')).toBeTruthy()
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
await Promise.resolve()
expect(screen.getByRole('tree')).toBeTruthy()
fireEvent.keyDown(screen.getByRole('tree'), { key: 'ArrowUp' })
})
it('navigates from outside the tree and tolerates a deferred focus after unmount', async () => {
@@ -64,7 +64,8 @@
line-height: 20px;
}
/* Search input: 38px capsule (figma 133:7649); rail state renders it as the
/* Search input: 38px bar, 12px radius (figma 133:7649 geometry, squared-off
corners); rail state renders it as the
region's search control. Upstream binds a dedicated design-system variable
(light #F1F3F5 / dark #1B1B1C) matching no shipped alias — a component
token pinned to the static scale mirrors it. */
@@ -79,7 +80,7 @@
padding: 0 14px;
box-sizing: border-box;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 24px;
border-radius: 12px;
background: var(--dsh-search-input-fill);
color: var(--dsw-alias-label-caption);
overflow: hidden;
-11
View File
@@ -1,11 +0,0 @@
# AGENTS.md — Experimental and internal packages
These rules supplement the [package rules](../AGENTS.md). The [experimental and internal package group decision](../../.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.md) owns the rationale.
- All Cordis plugin packages whose full public contract is experimental or internal-only belong here. An experimental option inside an otherwise stable package stays in that package's product-role group.
- Use this directory to share engineering and product-manager prototypes across the team so others can discover, run, review, and extend them against the real plugin graph.
- Official releases exclude this directory. A package enters a release only after moving to its product-role group; do not add packages here to release manifests or bundles.
- Experimental packages carry no stability, compatibility, migration, or support promise. Internal-only packages may define contracts for a limited set of internal callers and callees but make no public release promise.
- Experimental or internal-only status never relaxes repository engineering, security, documentation, lifecycle, testing, or snapshot requirements.
- Release packages must not take runtime dependencies on packages here. Examples may; every other runtime dependent is also experimental or internal-only and belongs here. Tests may use them as development dependencies.
- Promotion moves a package to its product-role group without renaming its `@deepseek-ai/dsh-*` package. Require explicit review of its public contract, limitations, test evidence, and a named owner accepting stable-package obligations.
-6
View File
@@ -1,6 +0,0 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/experimental/README.md
README.md: db39af8bb1b1bcfd257e16e4ad1dd112f604ffb1
README.zh.md: fc5942190a354668164b41b99e83b52b14d88f18
-7
View File
@@ -1,7 +0,0 @@
# experimental/ — experimental and internal packages
English | [中文](README.zh.md)
This group hosts team-shared engineering and product-manager prototypes plus internal-only Cordis plugins. It is excluded from official releases; packages move to their product-role group before release.
No packages live here yet. The [subtree rules](AGENTS.md) define the no-warranty, dependency, and promotion boundaries.
-7
View File
@@ -1,7 +0,0 @@
# experimental/:实验性与内部专用包
[English](README.md) | 中文
该分组容纳工程人员与产品经理在团队内共享的原型,以及内部专用 Cordis 插件。该分组不纳入官方发布版本;包在发布前移入对应的产品角色分组。
该分组尚未包含任何包。[子树规则](AGENTS.md)界定不作保证、依赖关系和提升机制的边界。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent 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: 52b8fb6a423fca69f76397deec36ecd22a6a6023
README.zh.md: ca74d53f2531a46c2c16aa1423cee52e89c8256f
README.md: 24a975476b6783b439d4ec94c449f2acbe0b432f
README.zh.md: 12a4dcace001442351916b17fca0d7e2f2c76245

Some files were not shown because too many files have changed in this diff Show More