diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml index 26d0ef4417..8771aecc10 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-06-14-session-persistence.md -2026-06-14-session-persistence.md: 55c1bbab94bb7854fd6bbcaace56c30dbcdb01dc -2026-06-14-session-persistence.zh.md: 16546ab61773da47064de8388803e92c8b454eae +2026-06-14-session-persistence.md: afe372c51f5056d02bdf3a17157ef755dd02e3f8 +2026-06-14-session-persistence.zh.md: 1fe84d955fe8878b715b3d23f05ecfc1fe5bbc78 diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md index 55c1bbab94..afe372c51f 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md @@ -15,7 +15,7 @@ The [event-sourced model](2026-06-11-event-sourced-sessions.md) makes the append Persistence is a **capability seam** with an abstract Service Definition ([capability seams](2026-06-13-capability-seams.md), the `dsh-shell` template), not loop or core logic: 1. **Interface** (`dsh-session-persistence`, `ctx.sessionPersistence`) — an abstract `SessionPersistence` service: `create`/`open`/`stat`/`list`/`flush`, with `create`/`open` returning per-session `SessionHandle`s that carry `read`/`append`/`flush`/`close` ([handle-based seam](2026-08-27-handle-based-session-persistence.md)). Its persisted unit IS the existing `SessionEvent` (`{ type, seq, time, data }`), reused verbatim — no conversion type. -2. **Implementation** (`dsh-session-persistence-jsonl`) — an append-only logical JSONL log per session: a `SessionHeader` line followed by storage records that losslessly represent the contiguous `SessionEvent` stream. Current v2 writes one event per row; frozen v0 and v1 readers retain their historical packed-delta representation. [Checksummed Zstandard frames](2026-07-19-zstandard-jsonl-session-logs.md) are the default physical encoding, with raw lines configurable. +2. **Implementation** (`dsh-session-persistence-jsonl`) — an append-only logical JSONL log per session: a `SessionHeader` line followed by storage records that losslessly represent the contiguous `SessionEvent` stream. The current format writes one event per row; frozen v0 and v1 readers retain their historical packed-delta representation. [Checksummed Zstandard frames](2026-07-19-zstandard-jsonl-session-logs.md) are the default physical encoding, with raw lines configurable. Key durable, contested choices: diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md index 16546ab617..1fe84d955f 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md @@ -15,7 +15,7 @@ Status: implemented 持久化是一个具有抽象 Service Definition 的**能力 seam**([能力 seam](2026-06-13-capability-seams.zh.md),`dsh-shell` 模板),而非循环或核心逻辑: 1. **接口**(`dsh-session-persistence`,`ctx.sessionPersistence`):一个抽象的 `SessionPersistence` 服务,提供 `create`/`open`/`stat`/`list`/`flush`,其中 `create`/`open` 返回逐会话的 `SessionHandle`,句柄承载 `read`/`append`/`flush`/`close`([基于句柄的 seam](2026-08-27-handle-based-session-persistence.zh.md))。其持久化单元就是现有的 `SessionEvent`(`{ type, seq, time, data }`),原样复用,无转换类型。 -2. **实现**(`dsh-session-persistence-jsonl`):每个会话一个仅追加的逻辑 JSONL 日志:先是一行 `SessionHeader`,随后是无损表示连续 `SessionEvent` 流的存储记录。当前 v2 每个事件写一行;冻结的 v0 与 v1 reader 保留其历史 packed-delta 表示。[带校验和的 Zstandard 帧](2026-07-19-zstandard-jsonl-session-logs.zh.md)是默认物理编码,也可通过配置使用原始行。 +2. **实现**(`dsh-session-persistence-jsonl`):每个会话一个仅追加的逻辑 JSONL 日志:先是一行 `SessionHeader`,随后是无损表示连续 `SessionEvent` 流的存储记录。当前格式每个事件写一行;冻结的 v0 与 v1 reader 保留其历史 packed-delta 表示。[带校验和的 Zstandard 帧](2026-07-19-zstandard-jsonl-session-logs.zh.md)是默认物理编码,也可通过配置使用原始行。 长期有效、存在争议的关键选择: diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml index 06c136e20b..07a0a60247 100644 --- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-14-provider-routed-llm-adapters.md -2026-07-14-provider-routed-llm-adapters.md: 24d6e7e439dc74a158801b647ddac96169730af1 -2026-07-14-provider-routed-llm-adapters.zh.md: f740468ed67830dabf5769eca206402d91c90aac +2026-07-14-provider-routed-llm-adapters.md: 1001b10e18837399b41578658ffe62065e294ba8 +2026-07-14-provider-routed-llm-adapters.zh.md: c5bff0ef407c8a2c22e8cad7e7d32c58c8b0e2da diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md index 24d6e7e439..1001b10e18 100644 --- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md +++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md @@ -54,7 +54,7 @@ Compaction configuration gains `summarizationProvider` beside `summarizationMode The JSON-RPC runtime receives provider and model explicitly. Its convenience fallback mounts `dsh-llm-deepseek` only for provider `deepseek` when that provider has no registered owner; other missing providers fail without guessing an adapter. -Current v1 seed/load validation rejects request headers and assistant messages that omit required provider/model fields. The frozen v0-to-v1 edge requires the same reconstructable routing identity before migration; it never guesses a missing provider or model, and malformed shapes refuse before publication. +Current seed/load validation rejects request headers and assistant messages that omit required provider/model fields. The frozen v0-to-v1 edge requires the same reconstructable routing identity before migration; it never guesses a missing provider or model, and malformed shapes refuse before publication. ## Alternatives considered @@ -78,7 +78,7 @@ Current v1 seed/load validation rejects request headers and assistant messages t - pi-ai credentials, transport knobs, SDK timeouts, and the five-minute-default `streamIdleTimeoutMs` watchdog are scoped per provider profile. Hidden provider retries are disabled; bounded retries belong to the separately composed agent recovery policy. - `dsh-llm-pi-ai` rejects stop sequences because pi-ai's common stream API cannot express them; the native DeepSeek adapter retains its stop support. - Replay state is portable only within the adapter instance that owns both the historical and target providers. Cross-provider and cross-model restoration is an adapter responsibility, and another adapter receives provider-neutral history without the opaque state. -- Current v1 Session JSONL requires provider/model on request headers and assistant messages. The v0 edge migrates only frozen shapes that already carry reconstructable request identity. +- Current Session JSONL requires provider/model on request headers and assistant messages. The v0 edge migrates only frozen shapes that already carry reconstructable request identity. ## Testing diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md index f740468ed6..c5bff0ef40 100644 --- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md @@ -54,7 +54,7 @@ pi-ai 回放状态用其成功 `AssistantMessage` 的带版本最小投影填充 JSON-RPC 运行时显式接收提供方与模型。仅当 `deepseek` 提供方没有注册所有者时,其便利回退才会挂载 `dsh-llm-deepseek`;其他缺失的提供方会直接失败,不会猜测适配器。 -当前 v1 的 seed/load 验证会拒绝省略必需提供方/模型字段的请求头和助手消息。冻结的 v0-to-v1 迁移边要求迁移前已具备同一套可重建路由身份;它绝不会猜测缺失的提供方或模型,畸形结构会在发布前被拒绝。 +当前的 seed/load 验证会拒绝省略必需提供方/模型字段的请求头和助手消息。冻结的 v0-to-v1 迁移边要求迁移前已具备同一套可重建路由身份;它绝不会猜测缺失的提供方或模型,畸形结构会在发布前被拒绝。 ## 考虑过的替代方案 @@ -78,7 +78,7 @@ JSON-RPC 运行时显式接收提供方与模型。仅当 `deepseek` 提供方 - pi-ai 凭据、传输选项、SDK 超时,以及默认五分钟的 `streamIdleTimeoutMs` 空闲超时机制均按提供方配置隔离。系统禁用隐藏的提供方重试;有界重试由单独组合的 agent 恢复策略负责。 - pi-ai 的通用流 API 无法表达停止序列,因此 `dsh-llm-pi-ai` 会拒绝停止序列;原生 DeepSeek 适配器仍支持停止序列。 - 仅当历史提供方与目标提供方归同一个适配器实例所有时,回放状态才可移植。适配器负责跨提供方和跨模型恢复;其他适配器只接收不含不透明状态的提供方无关历史。 -- 当前 v1 Session JSONL 要求请求头和助手消息都包含提供方/模型。v0 边只迁移已经携带可重建请求身份的冻结结构。 +- 当前 Session JSONL 要求请求头和助手消息都包含提供方/模型。v0 边只迁移已经携带可重建请求身份的冻结结构。 ## 测试 diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.i18n.yaml index c790fb48bd..01690045bd 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md -2026-07-30-session-end-seed-log-boundary.md: f615646ec332b650593bf6edc6597fe3d00614ba -2026-07-30-session-end-seed-log-boundary.zh.md: cf0ff6d03cab69746e1a90de28b5c15c3ba51b1b +2026-07-30-session-end-seed-log-boundary.md: 9cf7c7f20701688208e8b1c343904f2a9f2cab9e +2026-07-30-session-end-seed-log-boundary.zh.md: fdf4ec9de610f17e792de98334dd5d37f5e4c355 diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md index f615646ec3..9cf7c7f207 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md +++ b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md @@ -50,6 +50,6 @@ Bought: one boundary, written in one place, correct for all six seeded-start pat Cost: a seeded session's log is one event longer, including an empty resumed log. Seq expectations move with that boundary. Two updates are load-bearing rather than mechanical: telemetry's adoption tests assert that capture begins with the current lifecycle's newly appended boundary and excludes the constructor seed, and the property suite's replay invariant is "seed reproduced verbatim, plus one log-only boundary" with idempotence as its own property. -`session/end-seed` joins the on-disk vocabulary. Current v1 requires the validated marker semantics owned by Session; the frozen v0 codec and migration edge own which historical v0 seed layouts remain admissible. The exact inherited cut stays separate from the logical header and is available after a body read. +`session/end-seed` joins the on-disk vocabulary. The current format requires the validated marker semantics owned by Session; the frozen v0 codec and migration edge own which historical v0 seed layouts remain admissible. The exact inherited cut stays separate from the logical header and is available after a body read. The [queued manual compaction decision](../feature/2026-07-30-queued-manual-compaction.md) now supplies the first consumer. Its tail scan independently finds the unmatched `compaction/start` and newest end-seed, treats only a start after that boundary as live, and clears the invariant trace on the same replay transition. The predicate remains in the compaction package rather than becoming a generic core helper. diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md index cf0ff6d03c..fdf4ec9de6 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md @@ -50,6 +50,6 @@ Status: implemented 代价:带种子会话的日志长了一个事件,空日志恢复也包括在内。seq 期望会随这条边界移动。两处更新是承重的而非机械的:telemetry 的接管测试断言捕获从当前生命周期新追加的边界开始,并排除 constructor seed;属性测试套件的回放不变式则是「种子逐字节复现,外加一个仅日志边界」,并把幂等性作为独立属性。 -`session/end-seed` 加入了落盘词汇表。当前 v1 要求由 Session 拥有的已校验 marker 语义;冻结的 v0 codec 与迁移边负责哪些历史 v0 seed 布局仍可接受。精确继承 cut 与逻辑 header 分离,并在读取正文后可用。 +`session/end-seed` 加入了落盘词汇表。当前格式要求由 Session 拥有的已校验 marker 语义;冻结的 v0 codec 与迁移边负责哪些历史 v0 seed 布局仍可接受。精确继承 cut 与逻辑 header 分离,并在读取正文后可用。 [排队手动压缩决策](../feature/2026-07-30-queued-manual-compaction.zh.md)如今提供了第一个消费方。其尾部扫描会分别查找未匹配的 `compaction/start` 与最新 end-seed,只把位于该边界之后的 start 视为存活,并在同一个回放转换上清除不变量追踪状态。该谓词仍位于压缩功能所在的包中,不会成为通用核心辅助函数。 diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml index 95ba159dbf..811d459c8a 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md -2026-08-10-session-log-version-mechanism.md: 0f7f70b5ad6ecb2445729b1aa61fb3b295fc4ddb -2026-08-10-session-log-version-mechanism.zh.md: a6d58505ad9fdb6068a1afe48f7250f020d20a9e +2026-08-10-session-log-version-mechanism.md: 69ad02bcbdaab360063fac3b6e7d98960937677a +2026-08-10-session-log-version-mechanism.zh.md: fca404f15fcc7916ad8f431b491a1fd2e1faad75 diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md index 0f7f70b5ad..69ad02bcbd 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md +++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md @@ -16,7 +16,7 @@ Session logs must be upgradable after release, and the runtime that ships first **Read rules by direction.** Equal version: read normally. Newer than the reader: refuse, name the direction ("written by a newer harness — upgrade"), and point at the raw log artifact so the user can still see the text (`SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged). Older than the reader: every event-body operation first runs the complete adjacent chain in memory and leaves the source path, bytes, and inode unchanged. Read handles may consume that current logical result directly; a write open exclusively publishes the final current generation under its canonical versioned filename before append. Header-only listing remains non-mutating and reports the numerically highest canonical generation. Catalog generation and module initialization reject a missing adjacent step, so a published first-party build never exposes a partial historical chain. Retained lower generations are not automatic fallback or a downgrade compatibility promise. -**A per-event `ignorable` marker covers vocabulary growth, so ordinary event additions never bump the version.** The event vocabulary is decided by which plugins are mounted, which a single version integer cannot describe. A reader meeting an unrecognized event type refuses to interpret the log unless the event carries `ignorable: true` in its envelope. The default is *required*: forgetting the marker over-refuses a resumable session (an inconvenience), while a default of ignorable would make the same mistake silently resume a gutted one (a safety failure). The architecture makes this sound: model-visible content flows only through the three `surfaceOp`-marked surface event types plus the `request/header`/`request/context` folds, so the dangerous unknowns are exactly the non-surface events that change how the rest of the log is read (`session/end-seed` is the existing example). +**A per-event `ignorable` marker covers vocabulary growth, so ordinary event additions never bump the version.** The event vocabulary is decided by which plugins are mounted, which a single version integer cannot describe. A reader meeting an unrecognized event type refuses to interpret the log unless the event carries `ignorable: true` in its envelope. The default is *required*: forgetting the marker over-refuses a resumable session (an inconvenience), while a default of ignorable would make the same mistake silently resume a gutted one (a safety failure). The architecture makes this sound: model-visible content flows only through the four `surfaceOp`-marked surface event types plus the `request/header`/`request/context` folds, so the dangerous unknowns are exactly the non-surface events that change how the rest of the log is read (`session/end-seed` is the existing example). ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md index a6d58505ad..fca404f15f 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md @@ -16,7 +16,7 @@ Session log 在发布后必须能升级格式,而最先发布的运行时决 **读取规则按方向区分。**版本相等:正常读。比读取器新:拒绝,说明方向("由更新的 harness 写入,请升级"),并给出原始日志文件的路径,用户仍能看到文本(`SessionFormatUnsupportedError`,与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏)。比读取器旧:每个事件正文操作先在内存中运行完整相邻链,并保持源路径、字节与 inode 不变。读句柄可以直接使用该 current 逻辑结果;写 open 则在 append 前把最终 current generation 排他发布到其规范版本文件名。仅 header 的列表保持不变更,并报告数值最高的规范 generation。catalog 生成与模块初始化会拒绝缺失的相邻步骤,因此已发布第一方 build 绝不会暴露不完整历史链。保留的低 generation 不是自动 fallback,也不构成 downgrade compatibility 承诺。 -**逐事件的 `ignorable` 标记吸收词汇表增长,普通的新增事件永远不用升版本。**事件词汇表由挂载了哪些插件决定,单个版本整数描述不了它。读取器遇到不认识的事件类型时拒绝解读日志,除非该事件的信封带 `ignorable: true`。默认为必需:忘写标记的后果是把一个本可恢复的会话拒绝过头(体验问题),而默认可忽略会让同样的疏忽静默恢复出残缺会话(安全事故)。架构保证了这条规则成立:模型可见内容只经三种带 `surfaceOp` 标记的 surface 事件加 `request/header`、`request/context` 折叠进入重建,危险的未知事件恰好是那些不进 surface 但改变日志其余部分解读方式的事件(`session/end-seed` 是现存例子)。 +**逐事件的 `ignorable` 标记吸收词汇表增长,普通的新增事件永远不用升版本。**事件词汇表由挂载了哪些插件决定,单个版本整数描述不了它。读取器遇到不认识的事件类型时拒绝解读日志,除非该事件的信封带 `ignorable: true`。默认为必需:忘写标记的后果是把一个本可恢复的会话拒绝过头(体验问题),而默认可忽略会让同样的疏忽静默恢复出残缺会话(安全事故)。架构保证了这条规则成立:模型可见内容只经四种带 `surfaceOp` 标记的 surface 事件加 `request/header`、`request/context` 折叠进入重建,危险的未知事件恰好是那些不进 surface 但改变日志其余部分解读方式的事件(`session/end-seed` 是现存例子)。 ## 影响 diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml index e8b41ca73b..2d7c9e0a83 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md -2026-08-31-released-session-format-migrations.md: 286737730198ad895de2f03a48f9c74848839582 -2026-08-31-released-session-format-migrations.zh.md: 228b09e03916c1fa447398edb45508a7a6ab61e5 +2026-08-31-released-session-format-migrations.md: b10de9b20570bf6a9f7407d11c16af9b61a4a62c +2026-08-31-released-session-format-migrations.zh.md: 7707cfffe02f42c685741870e97b6a2f75af540c diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md index 2867377301..b10de9b205 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md +++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md @@ -76,7 +76,7 @@ Preset renames cover the creation header and every selection event because the l A source inherited count can be unknown before EOF: V2 derives it from seed markers, and V1→V2 can change cardinality. The chain passes that absence to the next stage instead of fabricating a count. The [V2-to-V3 inheritance rules](../../../../packages/session/session-format-v2-to-v3/README.md#sequence-references) support this case; older stages that require a header-supplied count still refuse when it is absent. This permits seeded multi-hop restoration without retaining an intermediate artifact array. -All structural changes compose in the one unreleased V2→V3 edge; feature or review order does not allocate extra Session format versions. V0, V1, and V2 generations remain byte-frozen, and migration publishes only the final V3 successor. The unreleased target can evolve until release, but an already-written V3 file does not rerun its incoming migration. Integration tests therefore require isolated disposable homes and unchanged historical inputs rather than rewriting committed generations. +V3 is a released Session format: [dsh-v0.1.5-alpha.1](https://github.com/deepseek-harness/deepseek-harness/releases/tag/dsh-v0.1.5-alpha.1) ships a [V3 writer](https://github.com/deepseek-harness/deepseek-harness/blob/dsh-v0.1.5-alpha.1/packages/core/session/src/types.ts#L88). An alpha product release still establishes a released persistence format. V0 through V3 retain their released semantics; committed generations remain byte-preserved during migration. A subsequent structural change requires the next adjacent edge under the [versioning rule](2026-08-10-session-log-version-mechanism.md), not an amendment to V2→V3. Ordinary event additions follow that rule’s required-event refusal mechanism rather than automatically allocating a version. An existing V3 file does not rerun its incoming migration; integration tests use isolated disposable homes and unchanged historical inputs. The [committed-corpus inventory](../../../../packages/test-support/llm-replay/tests/session-format-corpus-inventory.ts) identifies deliberately unsupported historical conversions by source path, generation, and exact refusal reason. Retaining those artifacts must not force chronology-changing migration or permit a blanket skip: every listed artifact must still raise the typed migration refusal, and unlisted artifacts must restore. Native current-generation fixtures cannot be classified as unsupported, because they do not traverse an incoming edge. Headerless test-harness protocol examples remain a separate explicit class. The corpus test checks source bytes after both successful and refused restoration; it does not rewrite historical evidence to satisfy the current reader. diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md index 228b09e039..7707cfffe0 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md @@ -76,7 +76,7 @@ Chain 中不存在 `flatMap`、spread expansion、中间 event array 或 schedul 源继承数量在 EOF 前可能未知:V2 从种子标记推导它,而 V1→V2 可以改变事件数量。迁移链将这种缺失传递给下一个 Stage,而不伪造数量。[V2 到 V3 继承规则](../../../../packages/session/session-format-v2-to-v3/README.zh.md#sequence-references)支持此情况;需要 header 提供数量的旧 Stage 仍在数量缺失时拒绝。这使有种子的多跳恢复无需保留中间产物数组。 -所有结构变更组合在唯一且尚未发布的 V2→V3 迁移边中;功能或评审顺序不分配额外 Session 格式版本。V0、V1、V2 代际保持字节冻结,迁移只发布最终 V3 后继代际。未发布的目标可以持续演化至发布,但已经写出的 V3 文件不会重新执行入边迁移。因此,集成测试必须使用隔离、可丢弃的 home 和未变更的历史输入,而非改写已提交代际。 +V3 是已发布的 Session 格式:[dsh-v0.1.5-alpha.1](https://github.com/deepseek-harness/deepseek-harness/releases/tag/dsh-v0.1.5-alpha.1) 已交付 [V3 写入器](https://github.com/deepseek-harness/deepseek-harness/blob/dsh-v0.1.5-alpha.1/packages/core/session/src/types.ts#L88)。产品的 alpha 发布同样确立已发布的持久化格式。V0 至 V3 保留各自已发布的语义;迁移期间已提交代际的字节保持不变。后续结构性变更必须按[版本规则](2026-08-10-session-log-version-mechanism.zh.md)添加下一条相邻迁移边,而非修改 V2→V3。普通事件新增遵循该规则的必需事件拒绝机制,而非自动分配版本。已有 V3 文件不会重新执行入边迁移;集成测试使用隔离、可丢弃的 home 和未变更的历史输入。 [已提交语料清单](../../../../packages/test-support/llm-replay/tests/session-format-corpus-inventory.ts) 按源路径、代际与精确拒绝原因标识有意不支持的历史转换。保留这些产物不能迫使迁移改变时序,也不能允许统一跳过:每个清单中的产物仍必须抛出类型化迁移拒绝,未列入的产物必须还原。原生当前代际 fixture 不经过入边,因此不能被归为不支持。没有版本 header 的测试框架协议示例保持为独立的显式类别。语料测试在还原成功和拒绝后都检查源字节;它不通过改写历史证据来满足当前 reader。 diff --git a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.i18n.yaml index 697c410f3a..c09aaa9dcb 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md -2026-09-01-parent-owned-subagent-catalog.md: 5926af77219680a48af1e5fb062ddc11a91a2280 -2026-09-01-parent-owned-subagent-catalog.zh.md: ddc77a665207169cfc048b9f2cfbd4f912cb93dc +2026-09-01-parent-owned-subagent-catalog.md: 20e2dd035b41612592e7baeb55f89322dff02848 +2026-09-01-parent-owned-subagent-catalog.zh.md: 6ec33e8be8d4373ba98a9934178a67e207ffb0bb diff --git a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md index 5926af7721..20e2dd035b 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md +++ b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md @@ -46,4 +46,4 @@ Current-writer snapshot expectations include catalog facts even when replay inpu Session observations and client snapshots expose the direct-child list through `projections.values.subagentCatalog`. The projection change feed publishes a complete list when catalog state changes. Each view costs O(D), so D creations can incur O(D²) cumulative view work; this follows the existing projection mechanism. Direct-child and descendant listing still use the Session corpus and child identity projection. -Backends that do not know the required event refuse the log under the existing Session event mechanism. Pre-release format policy requires no fallback scan for old logs. +Backends that do not know the required event refuse the log under the existing Session event mechanism. Catalog projection does not reconstruct missing parent facts by scanning old child logs. diff --git a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md index ddc77a6652..6ec33e8be8 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md @@ -46,4 +46,4 @@ snapshot normalizer 会把 `childCreatedAt` 归零,因为它来自 process clo Session 观察和客户端快照通过 `projections.values.subagentCatalog` 暴露直接子级列表。目录状态变化时,projection 变更通知发布完整列表。每次视图计算成本为 O(D),因此 D 次创建的累计视图工作量可能为 O(D²);这沿用既有 projection 机制。直接子级和后代列表仍使用 Session 语料库与子级身份 projection。 -不认识该 required event 的 backend 会按既有 Session event 机制拒绝日志。pre-release format policy 不要求为旧日志保留 fallback scan。 +不认识该 required event 的 backend 会按既有 Session event 机制拒绝日志。目录投影不通过扫描旧子级日志来重建缺失的父级事实。 diff --git a/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.i18n.yaml index 7b442c555f..12fdfd3b82 100644 --- a/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-09-02-system-prompt-as-surface-node.md -2026-09-02-system-prompt-as-surface-node.md: dc0d22b2fb927ad288415346bea9d0c2793cf000 -2026-09-02-system-prompt-as-surface-node.zh.md: 368684d85cb7ddf5d0be63bce905ce48e86cb49f +2026-09-02-system-prompt-as-surface-node.md: 1e374e4d30fc6c623c9cf1ee573375ee0c1ea1ad +2026-09-02-system-prompt-as-surface-node.zh.md: 8d5173dd0bf2814da323fc9fd7ca60160a9ac1bf diff --git a/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md b/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md index dc0d22b2fb..1e374e4d30 100644 --- a/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md +++ b/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md @@ -60,7 +60,7 @@ In `packages/core/agent-loop/src/agent.ts`, `preStep` renders the prompt with `r The [V2-to-V3 specification](../../../../packages/session/session-format-v2-to-v3/README.md#system-head) owns system-head conversion and message identities; its [reference rules](../../../../packages/session/session-format-v2-to-v3/README.md#sequence-references) and [source refusal](../../../../packages/session/session-format-v2-to-v3/README.md#source-audit) define preservation and unsupported inputs. The migrated layout is semantically equivalent to native requests, not byte-identical to a native recording. A valid V2 source can lack an order-preserving conversion under the current step invariant; refusing it is preferable to moving history or relaxing ownership. Historical acceptance coordinates must not become acknowledgements of the transformed log. -The [released-format policy](2026-08-31-released-session-format-migrations.md) keeps V0, V1, and V2 generations byte-frozen and publishes only V3 successors. V3 is one unreleased target, not a new version per feature; it can evolve before release, so integration requires disposable homes. An existing V3 generation does not rerun V2-to-V3. Projection-cache version 4 is independent of the Session format and does not imply Session V4. +The [released-format policy](2026-08-31-released-session-format-migrations.md) covers V3 as well as V0, V1, and V2. The V2-to-V3 conversion preserves its released semantics; an existing V3 generation does not rerun that edge. Projection-cache version 4 is independent of the Session format and does not imply Session V4. The [canonical-envelope specification](../../../../packages/session/session-format-v2-to-v3/README.md#canonical-envelopes) defines composition with the structural conversion; the [canonical-envelope decision](2026-09-06-v3-canonical-session-envelopes.md) owns the strict-acceptance rationale. diff --git a/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md b/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md index 368684d85c..8d5173dd0b 100644 --- a/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md @@ -60,7 +60,7 @@ Status: implemented [V2 到 V3 规范](../../../../packages/session/session-format-v2-to-v3/README.zh.md#system-head)负责系统头节点转换与消息身份;其[引用规则](../../../../packages/session/session-format-v2-to-v3/README.zh.md#sequence-references)和[源拒绝](../../../../packages/session/session-format-v2-to-v3/README.zh.md#source-audit)定义保留内容与不支持的输入。迁移布局与原生请求语义等价,而非与原生录制逐字节相同。有效 V2 源在当前步骤不变量下可能没有保持顺序的转换方式;拒绝它优于移动历史或放宽归属。历史接收坐标不得变为对转换后日志的确认。 -[已发布格式策略](2026-08-31-released-session-format-migrations.zh.md)保持 V0、V1、V2 代际字节冻结,并且只发布 V3 后继代际。V3 是一个尚未发布的目标,而不是每个功能一个新版本;它在发布前可以演化,因此集成必须使用可丢弃的 home。已有 V3 代际不会重跑 V2-to-V3。投影缓存版本 4 独立于 Session 格式,并不意味着 Session V4。 +[已发布格式策略](2026-08-31-released-session-format-migrations.zh.md)既覆盖 V0、V1、V2,也覆盖 V3。V2-to-V3 转换保留其已发布的语义;已有 V3 代际不会重跑该迁移边。投影缓存版本 4 独立于 Session 格式,并不意味着 Session V4。 [规范信封规范](../../../../packages/session/session-format-v2-to-v3/README.zh.md#canonical-envelopes)定义与结构转换的组合;[规范信封决策](2026-09-06-v3-canonical-session-envelopes.zh.md)负责严格准入的依据。 diff --git a/.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.i18n.yaml index 45b4faa5d6..c71a86687b 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-09-05-read-only-session-migration-preparation.md -2026-09-05-read-only-session-migration-preparation.md: ab23e7e61dcdf0762cae6185de5fd16c4070fcbf -2026-09-05-read-only-session-migration-preparation.zh.md: 89377d4d84776bebbc6d2ca6acea92ac15f74df3 +2026-09-05-read-only-session-migration-preparation.md: 081044e34d5071ba242792c588f43a3b83adddd5 +2026-09-05-read-only-session-migration-preparation.zh.md: 7406e7cf31112df6c9f6bb0d454f38d0cf7eb6a4 diff --git a/.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.md b/.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.md index ab23e7e61d..081044e34d 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.md +++ b/.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.md @@ -65,7 +65,7 @@ Completed results enter the existing bounded `coldLogMemo`. The `StoredLog` disc `SessionHandle.read()` reports whether its event values are detached or shared-frozen. The JSONL backend deep-freezes each decoded event graph once before memoization and creates the `shared-frozen` result there; later reads and slices preserve that producer-established state even when the slice is empty. `readColdSessionLog()` combines those values with locally owned interrupted-turn closers and passes the `eventState` through `SessionObservationReader`; `Session.fromRestore()` validates and adopts the seed without copying or freezing. Ordinary create and fork seeds keep their defensive snapshot path. -Read-only restoration validates the event and settlement fields required by Session runtime behavior but does not expand every embedded Assistant stream. The publication Worker retains complete stream replay and checks content, usage, and replay-state agreement before a migrated successor is committed. Existing current-v2 files rely on their writer; consumers that expand a compact stream validate its records when they read it. +Read-only restoration validates the event and settlement fields required by Session runtime behavior but does not expand every embedded Assistant stream. The publication Worker retains complete stream replay and checks content, usage, and replay-state agreement before a migrated successor is committed. Existing current-format files rely on their writer; consumers that expand a compact stream validate its records when they read it. ### Read handle transition diff --git a/.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.zh.md b/.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.zh.md index 89377d4d84..7406e7cf31 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.zh.md @@ -65,7 +65,7 @@ interface MigrationPreparation { `SessionHandle.read()` 会报告 event value 是 detached 还是 shared-frozen。JSONL backend 在 memo 化前只对每个已解码 event graph 深度冻结一次,并在该处构造 `shared-frozen` 结果;后续读取和 slice 即使为空也会保留生产者建立的状态。`readColdSessionLog()` 将这些 event 与本地独占的 interrupted-turn closer 组合,并通过 `SessionObservationReader` 继续传递 `eventState`;`Session.fromRestore()` 只校验和接管 seed,不再复制或冻结。普通 create 与 fork seed 继续使用 defensive snapshot 路径。 -Read-only restoration 会校验 Session runtime 直接依赖的 event 与 settlement 字段,但不会展开每一段嵌入式 Assistant stream。Publication Worker 继续执行完整 stream replay,并在提交 migrated successor 前校验 content、usage 与 replay state 一致性。已有 current-v2 文件信任其 writer;需要展开 compact stream 的 consumer 会在读取时校验 record。 +Read-only restoration 会校验 Session runtime 直接依赖的 event 与 settlement 字段,但不会展开每一段嵌入式 Assistant stream。Publication Worker 继续执行完整 stream replay,并在提交 migrated successor 前校验 content、usage 与 replay state 一致性。已有当前格式文件信任其 writer;需要展开 compact stream 的 consumer 会在读取时校验 record。 ### Read handle 切换 diff --git a/.agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.i18n.yaml new file mode 100644 index 0000000000..c971fb1bfd --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.md +2026-09-09-composer-placeholder-whitespace.md: c5d778f8fb1fdfc742636819a9ff27ddeeb0234e +2026-09-09-composer-placeholder-whitespace.zh.md: b5f82bcd86f8567976bba96095d4de9d42bae9b1 diff --git a/.agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.md b/.agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.md new file mode 100644 index 0000000000..c5d778f8fb --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.md @@ -0,0 +1,21 @@ +# Agent Note: Composer placeholder emptiness + +Status: implemented + +English | [中文](2026-09-09-composer-placeholder-whitespace.zh.md) + +## Problem + +Sharing the whitespace-trimmed submission check with placeholder rendering leaves guidance drawn over a draft containing spaces. + +## Decision + +The Composer hides its placeholder whenever the raw draft is nonempty. Submission keeps its trimmed-content check. Attachments and claimed commands retain their existing placeholder suppression. + +## Alternatives considered + +**Reuse the submission check.** Whitespace has no sendable message content, but it occupies the editor and moves its caret. A shared check conflates these two states. + +## Consequences + +All placeholder variants, including queued-message steering guidance, disappear after whitespace input and return after deletion. A whitespace-only draft without attachments remains unsendable. [Component tests](../../../../packages/client/ui-conversation/tests/input-bar.client.spec.tsx) cover visibility, composition, rerendering and submission; the [browser regression](../../../../apps/web/tests/composer-placeholder.e2e.ts) checks keyboard and clipboard gestures against built UI. diff --git a/.agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.zh.md b/.agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.zh.md new file mode 100644 index 0000000000..b5f82bcd86 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.zh.md @@ -0,0 +1,21 @@ +# Agent Note: Composer 占位提示的判空规则 + +Status: implemented + +[English](2026-09-09-composer-placeholder-whitespace.md) | 中文 + +## Problem + +占位提示复用去除首尾空白后的提交判断,会让提示文字覆盖已经包含空格的草稿。 + +## Decision + +原始草稿非空时,Composer 隐藏占位提示。提交仍检查去除首尾空白后的内容。附件和已认领指令沿用现有的占位提示隐藏规则。 + +## Alternatives considered + +**复用提交判断。** 空白字符没有可发送的消息内容,但会占据编辑器并移动光标。共用判断会混淆这两种状态。 + +## Consequences + +所有占位提示,包括排队消息的插话提示,都会在输入空白字符后隐藏,删除后恢复。没有附件的纯空白草稿仍无法发送。[组件测试](../../../../packages/client/ui-conversation/tests/input-bar.client.spec.tsx) 覆盖显示、输入法组合、重新渲染和提交;[浏览器回归](../../../../apps/web/tests/composer-placeholder.e2e.ts) 使用构建后的界面检查键盘和剪贴板操作。 diff --git a/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.i18n.yaml new file mode 100644 index 0000000000..3d3ce530c3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md +2026-09-08-feedback-dialog-and-categories.md: 02a41f08541cca85dad7cd3c2c984b5e7cd8fd6d +2026-09-08-feedback-dialog-and-categories.zh.md: 40d83360e52bff569120820b78959a7cc3f4db4f diff --git a/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md new file mode 100644 index 0000000000..02a41f0854 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md @@ -0,0 +1,33 @@ +# Agent Note: Feedback dialog, categories, and the acknowledgement toast + +Status: implemented + +English | [中文](2026-09-08-feedback-dialog-and-categories.zh.md) + +## Problem + +The Web client had two disconnected feedback paths with no visible outcome. `/feedback ` recorded a Session remark and rendered an acknowledgement row in the transcript; the Like/Dislike pair recorded a rating at once, with a note popover anchored under the row for free text. Neither path told the user what was submitted or where it went, neither collected a category, and a Dislike, the case in which a user is most willing to explain, asked nothing. Issue #3515 and the design doc for it ask for one dialog reachable from the composer menu, from a bare `/feedback`, and from Dislike, with seven fixed categories, an optional description, a success toast, and a filled glyph for a recorded rating, while Like keeps recording at once. + +## Decision + +`command-feedback` owns the category taxonomy as the `FeedbackCategory` union and the `FEEDBACK_CATEGORIES` tuple in its client-safe `./types` export, and `feedback/record` becomes `{ text?, category? }`: blank text is recorded as absent, and an entry with neither member still records, because the log delivery that the feedback authorizes is the content. The same package publishes the `sessionFeedback.record` Remote through `TypertRemoteService`, resolving the live Session by id and calling the existing `recordFeedback` producer, so the dialog records the same event as the command without command bookkeeping. `message-feedback` adds the optional `category` to `MessageFeedbackItem` and `MessageFeedbackPutRequest`, validates stored values against the tuple, and counts a category change as a material edit. + +`ui-message-feedback` becomes the Web feedback surface. A per-session `FeedbackSurface` owns the message-feedback controller, a `FeedbackDialogController` for the draft, the submission, and the toast sequence, and the routing between them: a message target puts a negative judgment with the dialog's category and note through the message controller, the Session target records through `ctx.remote.sessionFeedback`. A `FeedbackDialog` entry of `conversation.input.overlay` renders the Modal and Toast primitives from the dialog store. A decoration on the Host's `feedback` command opens the dialog for the Session from a menu pick or a bare Enter while `/feedback ` still reaches the Host; it uses the `action` kind this PR adds to `CommandUiSpec`, a bare invocation that consumes the trigger token and runs a client callback without submitting anything. Dislike opens the same dialog for the message. Like calls `toggle`, which now reports the rating it committed, so the row acknowledges a recorded Like and stays silent on a retraction. The note popover, `clearNote`, and `clear` are removed: the dialog is the only note editor, a rating switch stores the bare judgment, and clicking a recorded rating retracts it. + +The dialog is the shared Modal card at the design's width; the design's checkbox for including the conversation log is not built, because the log travels with every feedback event and is not optional. An oversized description still fails on submit with `note-too-large`; the dialog stays open with the code. + +## Alternatives considered + +**Encode the category into the note text.** A prefix in free text is not filterable without parsing and would leak into the verbatim note that telemetry uploads; a durable id in the payload is what a consumer can group by. + +**Submit the dialog through the command plane as `/feedback `.** The command rejects empty text, cannot carry a category, and writes an acknowledgement row the design replaces with a toast; the Remote records the same event with neither constraint. + +**Keep the note popover beside the dialog.** Two editors for one note with different reachability would leave the row two-line at some widths, the defect the popover was introduced to avoid, and the design shows only the thumbs. + +**A Toast per message control.** The composer overlay already mounts once per Session, and the dialog owns the toast sequence, so one owner serves the Like path and the dialog path alike. + +**A dialog kind in `CommandUiSpec`.** An action that consumes the token and runs a client callback is all the dialog needs; PR #3745 introduces the same `action` kind for its File row, so whichever lands second keeps one definition. + +## Consequences + +Adding a category means adding it to the union, to the Host tuple, to the dialog's chip record, and to the `feedback` dictionaries; the client bundle purity gate forbids a value import from a Host package, so the dialog restates the taxonomy as a `Record` whose key order is the chip order and whose completeness the compiler checks. The frozen released-v2 payload inventory still lists `feedback/record` as `text` only: it governs artifacts migrated from older generations, which cannot carry the new members, while equal-version restoration applies the installed vocabulary. The `message-feedback-layout` web scenario that pinned the popover's geometry is deleted with the popover. The message-feedback and feedback-release web goldens and the feedback subsystem doc changed in the same PR; the SDK feedback producer records a categorized Session remark and a categorized Dislike, so both SDK expected outputs carry the new members. diff --git a/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.zh.md b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.zh.md new file mode 100644 index 0000000000..40d83360e5 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 反馈弹窗、分类与确认 toast + +Status: implemented + +[English](2026-09-08-feedback-dialog-and-categories.md) | 中文 + +## 问题 + +Web 客户端有两条互不相连的反馈路径,且都没有可见结果。`/feedback ` 记录一条 Session 备注并在转录里渲染一行确认;赞踩对立即记录评分,自由文本则通过锚定在该行下方的备注浮层填写。两条路径都不告诉用户提交了什么、去了哪里,都不收集分类,而点踩这个用户最愿意解释的场景什么也不问。Issue #3515 及其设计稿要求:一个弹窗,可从输入框菜单、不带文本的 `/feedback` 和点踩三处打开,带七个固定分类、可选描述、成功 toast,以及已记录评分的实心图标;点赞保持立即记录。 + +## 决策 + +`command-feedback` 在其客户端可用的 `./types` 导出中以 `FeedbackCategory` 联合类型与 `FEEDBACK_CATEGORIES` 元组拥有分类表,`feedback/record` 变为 `{ text?, category? }`:空白文本记为缺省,两个成员都没有的条目仍会记录,因为反馈所授权的日志投递本身就是内容。同一个包通过 `TypertRemoteService` 发布 `sessionFeedback.record` Remote,按 id 找到 live Session 后调用已有的 `recordFeedback` 生产方,因此弹窗记录的是与命令相同的事件,只是没有命令簿记。`message-feedback` 给 `MessageFeedbackItem` 与 `MessageFeedbackPutRequest` 加上可选 `category`,按元组校验已存值,并把分类变化算作实质编辑。 + +`ui-message-feedback` 成为 Web 反馈界面。每个 Session 一个 `FeedbackSurface`,拥有消息反馈控制器、负责草稿、提交与 toast 序号的 `FeedbackDialogController`,以及两者之间的路由:消息目标经消息控制器 put 一条带弹窗分类与备注的差评,Session 目标经 `ctx.remote.sessionFeedback` 记录。`conversation.input.overlay` 的 `FeedbackDialog` 条目从弹窗 store 渲染 Modal 与 Toast 基元。宿主 `feedback` 命令上的装饰让菜单选中或不带参数的回车为 Session 打开弹窗,而 `/feedback ` 仍到达宿主;它使用本 PR 给 `CommandUiSpec` 新增的 `action` 种类:裸调用消费触发 token 后运行一个客户端回调,不提交任何内容。点踩为消息打开同一个弹窗。点赞调用 `toggle`,它现在会报告自己提交的评分,因此该行只对记录成功的点赞做确认,撤回时保持沉默。备注浮层、`clearNote` 与 `clear` 被移除:弹窗是唯一的备注编辑器,切换评分只存判断本身,再次点击已记录的评分即撤回。 + +弹窗是共用的 Modal 卡片,宽度按设计稿;设计稿里「包括当前对话的日志」复选框不做,因为日志随每个反馈事件一起投递,不是可选项。超长描述仍在提交时以 `note-too-large` 失败;弹窗带着失败码保持打开。 + +## 考虑过的替代方案 + +**把分类编进备注文本。** 自由文本里的前缀不解析就无法过滤,还会混进遥测上传的原样备注;载荷里的持久 id 才是消费方能分组的东西。 + +**让弹窗经命令平面以 `/feedback ` 提交。** 命令拒绝空文本、带不了分类,还会写一行设计稿已用 toast 取代的确认;Remote 记录同一个事件且没有这两个约束。 + +**在弹窗之外保留备注浮层。** 同一条备注有两个可达性不同的编辑器,会让该行在某些宽度下变成两行,正是当初引入浮层要避免的缺陷,而且设计稿只有两个拇指。 + +**每个消息控件各自一个 Toast。** 输入框浮层已经按 Session 挂载一次,弹窗又拥有 toast 序号,因此一个持有者同时服务点赞路径与弹窗路径。 + +**在 `CommandUiSpec` 里新增 dialog 种类。** 一个消费 token 后运行客户端回调的 action 已经够用;PR #3745 为它的「文件」行引入了同一个 `action` 种类,后合并的一方保留一份定义即可。 + +## 后果 + +新增分类意味着把它加进联合类型、宿主元组、弹窗的标签记录和 `feedback` 词典;客户端打包纯度门禁止从宿主包做值导入,因此弹窗以 `Record` 重述分类表,键的顺序就是标签顺序,完整性由编译器检查。冻结的已发布 v2 载荷清单仍把 `feedback/record` 列为仅有 `text`:它管辖从旧代际迁移来的产物,那些产物不可能携带新成员,而同版本恢复应用的是已安装词汇。固定浮层几何的 `message-feedback-layout` Web 场景随浮层一起删除。message-feedback 与 feedback-release 的 Web 期望输出和反馈子系统文档在同一个 PR 中更新;SDK 的反馈生产方会记录一条带分类的 Session 备注和一条带分类的差评,因此两个 SDK 期望输出都携带新成员。 diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml index 27af3c5438..9d992637b1 100644 --- a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-09-08-present-workspace-source-files.md -2026-09-08-present-workspace-source-files.md: 6239c9bd920849f3c8cf4fdee2e1ded4b758b01d -2026-09-08-present-workspace-source-files.zh.md: 7aa5bebc97558b9b0cb74406303d038483c39d94 +2026-09-08-present-workspace-source-files.md: 6a8ebfcf1fd7be22a5bda5a209d0fdc3586931bb +2026-09-08-present-workspace-source-files.zh.md: d9361f2281eeb027e4c44b84c7f01af1639b66f2 diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md index 6239c9bd92..6a8ebfcf1f 100644 --- a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md +++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md @@ -12,9 +12,11 @@ Users need to open and edit the files produced in their workspace, including she The [present tool](../../../../packages/fs/tool-present/README.md) declares existing regular files inside the calling Session's workspace. It records paths and optional descriptions without reading or copying contents. The [deliverables plugin](../../../../packages/client/ui-deliverables/README.md) opens current workspace sources in the Host's default application. Edits are visible on the next open; deletion or movement makes the declaration unavailable. File-content preservation and copy-on-write storage are deferred until a persistence design owns them. +The tool description requires `present` after writing a file the user asked to receive and before the final response, including files created through Bash or code execution. A prose path reference does not replace the call. The recorded [SVG delivery scenario](../../../../snapshots/web/present-svg/snapshot.yml) uses a user request that does not name `present`, and checks the resulting file, delivery event, and card. Its UI snapshot covers the expanded Chat transcript; navigation and composer controls belong to their own scenarios, so unrelated chrome changes cannot invalidate file-delivery expectations. + The tool remains an ordinary package with shared filesystem and tool error classes. Its pure type entry owns the delivery event without importing Host code into the browser. The `standard`, `ptc`, and `cordis` presets mount it; `minimal` retains its two tools. Each plugin instance correlates its executions with successful final `tools/result` notifications before appending `deliverables/presented`. Native and nested calls share this rule. A later enclosing program failure does not revoke a completed nested declaration; blocked results publish none, and same-name scoped replacements cannot publish another instance's results. -An authenticated POST selects a declaration by viewed Session, event sequence, and original file index. The event carries no owning Session ID; relative paths in inherited history resolve against the viewed Session's workspace. The Host rechecks canonical workspace containment and regular-file existence before native opening. Route disposal cancels and awaits pending commands. The existing produced-file row retains its separate text-preview behavior. +An authenticated POST selects a declaration by viewed Session, event sequence, and original file index. The event carries no owning Session ID; relative paths in inherited history resolve against the viewed Session's workspace. The Host rechecks canonical workspace containment and regular-file existence before native opening. Route disposal cancels and awaits pending commands. The “Files changed” row lists successful file-tool mutations and retains its separate text-preview behavior. Its Chinese label is “本轮文件改动”; neither label implies final delivery. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md index 7aa5bebc97..d9361f2281 100644 --- a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md +++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md @@ -12,9 +12,11 @@ Status: implemented [present 工具](../../../../packages/fs/tool-present/README.zh.md)声明交付调用方 Session 工作区中已存在的普通文件。它记录路径和可选说明,不读取或复制内容。[交付插件](../../../../packages/client/ui-deliverables/README.zh.md)使用 Host 默认应用打开当前工作区源文件。下次打开会看到编辑后的内容;删除或移动文件会使声明不可用。文件内容保留与写时复制存储延期到有持久化设计负责时实现。 +工具说明要求在写好用户要求接收的文件后、最终回复前调用 `present`,包括通过 Bash 或代码执行创建的文件。正文中的路径引用不能替代调用。录制的 [SVG 交付场景](../../../../snapshots/web/present-svg/snapshot.yml)使用未提及 `present` 的用户请求,检查生成文件、交付事件和卡片。其 UI 快照覆盖展开后的 Chat 对话内容;导航和输入框控件由各自场景负责,避免无关界面改动使文件交付预期失效。 + 工具保持为普通包,共享文件系统和工具错误类型。其纯类型入口拥有交付事件,不向浏览器导入 Host 代码。`standard`、`ptc` 与 `cordis` preset 挂载工具;`minimal` 保持两个工具。每个插件实例将其执行与成功的最终 `tools/result` 通知关联,再追加 `deliverables/presented`。原生与嵌套调用遵循同一规则。外层程序随后失败不会撤销已完成的嵌套声明;被阻止的结果不发布声明,同名作用域替换也不能发布其他实例的结果。 -经过认证的 POST 按当前查看的 Session、事件序号和原始文件索引选择声明。事件不携带所属 Session ID;继承历史中的相对路径按当前查看的 Session 工作区解析。Host 在原生打开前重新检查规范路径的工作区包含关系和普通文件是否存在。路由释放时取消并等待进行中的命令。原有产出文件行保留独立的文本预览行为。 +经过认证的 POST 按当前查看的 Session、事件序号和原始文件索引选择声明。事件不携带所属 Session ID;继承历史中的相对路径按当前查看的 Session 工作区解析。Host 在原生打开前重新检查规范路径的工作区包含关系和普通文件是否存在。路由释放时取消并等待进行中的命令。“本轮文件改动”行列出成功的文件工具修改,并保留独立的文本预览行为。其英文标签为“Files changed”;两个标签均不表示最终交付。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/process/2026-09-09-blocked-weighted-approvals-remain-pending.i18n.yaml b/.agents/notes/implemented/process/2026-09-09-blocked-weighted-approvals-remain-pending.i18n.yaml new file mode 100644 index 0000000000..8817e9561a --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-09-blocked-weighted-approvals-remain-pending.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-09-blocked-weighted-approvals-remain-pending.md +2026-09-09-blocked-weighted-approvals-remain-pending.md: 50ab64dadc0d7ad14366950d24b4136d131f55ec +2026-09-09-blocked-weighted-approvals-remain-pending.zh.md: 58ab10b3acb3bee92c720b622d92170a1a8bcf03 diff --git a/.agents/notes/implemented/process/2026-09-09-blocked-weighted-approvals-remain-pending.md b/.agents/notes/implemented/process/2026-09-09-blocked-weighted-approvals-remain-pending.md new file mode 100644 index 0000000000..50ab64dadc --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-09-blocked-weighted-approvals-remain-pending.md @@ -0,0 +1,33 @@ +# Agent Note: Blocked weighted approvals remain pending + +Status: implemented + +English | [中文](2026-09-09-blocked-weighted-approvals-remain-pending.zh.md) + +## Problem + +The weighted approval commit status must distinguish an unmet merge condition from a failed policy evaluation. An effective `CHANGES_REQUESTED` review from a write-capable reviewer prevents a pull request from satisfying the approval policy, but it is a reversible review state rather than an evaluation failure. + +Publishing `failure` for that review state conflates the approval decision with the health of the publisher. It also treats one unmet policy condition differently from a draft pull request or insufficient approval points, which remain pending while contributors can resolve them. + +## Decision + +A completed weighted approval evaluation publishes `pending` when the pull request is a draft, has fewer than the required approval points, or has an effective `CHANGES_REQUESTED` review from a write-capable reviewer. A blocking review dominates the point total, so the status remains pending even when counted approvals reach the threshold. + +The evaluation publishes `success` only when the pull request is ready, the point threshold is met, and no blocking review exists. The separate `weighted approval publisher` Actions job reports whether evaluation and status publication completed. An evaluation failure publishes an `error` commit status and fails that job. + +## Verification + +[Approval policy tests](../../../../.github/review-ownership/check-approval.test.mjs) pin the threshold-reaching blocker case and the exact published `pending` payload. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) pin the separate publisher job name. + +## Alternatives considered + +**Publish `failure` for a blocking review.** This keeps a visibly failed status until the review changes, but it represents an unmet and reversible merge condition as a malfunction and conflates policy outcome with publisher health. + +**Let approval points override a blocking review.** This makes the score the only success condition, but it permits a successful status while a write-capable reviewer's effective decision still requests changes. + +## Consequences + +Required-status branch rules block a pull request because `pending` does not satisfy the required status. Contributors can distinguish review work that remains from a failed approval evaluation, while the publisher job and `error` status retain the operational failure signal. + +Consumers do not receive a failed commit status solely because a blocking review exists. They must inspect the status description or effective reviews when they need to distinguish a blocker from other pending approval conditions. diff --git a/.agents/notes/implemented/process/2026-09-09-blocked-weighted-approvals-remain-pending.zh.md b/.agents/notes/implemented/process/2026-09-09-blocked-weighted-approvals-remain-pending.zh.md new file mode 100644 index 0000000000..58ab10b3ac --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-09-blocked-weighted-approvals-remain-pending.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 阻塞中的加权批准保持 pending + +Status: implemented + +[English](2026-09-09-blocked-weighted-approvals-remain-pending.md) | 中文 + +## 问题 + +`weighted approval` commit status 必须区分尚未满足的合并条件与失败的策略评估。具有写权限的评审人所提交且仍然生效的 `CHANGES_REQUESTED` 评审会阻止 PR 满足批准策略,但它是一种可撤销的评审状态,而不是评估故障。 + +为这种评审状态发布 `failure` 会混淆批准决策与 publisher 的健康状态。它还会让一个尚未满足的策略条件区别于 draft PR 或批准点数不足;后两种情况在贡献者能够解决问题期间会保持 pending。 + +## 决策 + +完成的加权批准评估会在 PR 为 draft、批准点数少于要求,或具有写权限的评审人存在仍然生效的 `CHANGES_REQUESTED` 评审时发布 `pending`。阻塞性评审的优先级高于点数总和,因此即使计入的批准已经达到阈值,状态仍保持 pending。 + +只有在 PR 已进入 ready 状态、达到点数阈值且不存在阻塞性评审时,评估才发布 `success`。独立的 `weighted approval publisher` Actions job 报告评估和状态发布是否完成。评估故障会发布 `error` commit status,并使该 job 失败。 + +## 验证 + +[批准策略测试](../../../../.github/review-ownership/check-approval.test.mjs)锁定已达到阈值但仍有 blocker 的场景,以及准确发布的 `pending` payload。[工作流测试](../../../../scripts/ci-workflow.spec.ts)锁定独立的 publisher job 名称。 + +## 考虑过的替代方案 + +**为阻塞性评审发布 `failure`。** 该方案会在评审改变之前保持明显的失败状态,但它会把尚未满足且可撤销的合并条件表示为故障,并混淆策略结果与 publisher 的健康状态。 + +**允许批准点数覆盖阻塞性评审。** 该方案会让分数成为唯一的成功条件,但也允许在具有写权限的评审人仍然有效地要求修改时发布成功状态。 + +## 后果 + +需要该状态的分支规则会阻止 PR,因为 `pending` 不满足必需状态。贡献者可以区分尚待处理的评审工作与失败的批准评估,而 publisher job 和 `error` 状态保留运行故障信号。 + +消费方不会仅因存在阻塞性评审而收到失败的 commit status。如果需要区分 blocker 与其他 pending 批准条件,它们必须检查状态描述或仍然生效的评审。 diff --git a/.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.i18n.yaml b/.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.i18n.yaml index 67ce1cb560..a7506a8eeb 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-06-20-collapse-trace-only-session-events.md -2026-06-20-collapse-trace-only-session-events.md: e7c40cbde6dd666542bb4022064a1be97b0e0ce8 -2026-06-20-collapse-trace-only-session-events.zh.md: b2c062fc8138a120da9467250b50772083adca75 +2026-06-20-collapse-trace-only-session-events.md: f83d93a899d59eb3ebb3f22f36e32aa7865bf902 +2026-06-20-collapse-trace-only-session-events.zh.md: e53544aed0d3b88c7eb85cd1e9a4f12af7686256 diff --git a/.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.md b/.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.md index e7c40cbde6..f83d93a899 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.md +++ b/.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.md @@ -27,7 +27,7 @@ The user conversation log contains what is needed to render, resume, audit, and ## Verification -`SessionEventMap` carries no standalone `usage` or `error`; the loop appends no separate usage event and records durable failures through `turn/end { kind: 'error', step, message, code? }`; ACP snapshots and persistence tests assert no trace-only lines; the frozen v0 codec and identity migration preserve this released representation into current v1; and the docs state where token usage and operational errors are observed. +`SessionEventMap` carries no standalone `usage` or `error`; the loop appends no separate usage event and records durable failures through `turn/end { kind: 'error', step, message, code? }`; ACP snapshots and persistence tests assert no trace-only lines; the frozen v0 codec and identity migration preserve this released representation into released v1; and the docs state where token usage and operational errors are observed. ## Consequences diff --git a/.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.zh.md b/.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.zh.md index b2c062fc81..e53544aed0 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.zh.md +++ b/.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.zh.md @@ -27,7 +27,7 @@ Status: implemented ## 验证 -`SessionEventMap` 不再包含独立的 `usage` 或 `error`;agent loop(智能体循环)不再追加独立的 usage 事件,并通过 `turn/end { kind: 'error', step, message, code? }` 持久记录失败;ACP 快照和持久化测试断言不存在仅用于追踪的行;冻结的 v0 codec 与恒等迁移会把该已发布表示保留到当前 v1;文档说明了 token 用量和运行错误的观测位置。 +`SessionEventMap` 不再包含独立的 `usage` 或 `error`;agent loop(智能体循环)不再追加独立的 usage 事件,并通过 `turn/end { kind: 'error', step, message, code? }` 持久记录失败;ACP 快照和持久化测试断言不存在仅用于追踪的行;冻结的 v0 codec 与恒等迁移会把该已发布表示保留到已发布 v1;文档说明了 token 用量和运行错误的观测位置。 ## 后果 diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml index 30419f63e7..4376b51c83 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/testing/2026-06-19-acp-snapshot-tests.md -2026-06-19-acp-snapshot-tests.md: da02393fef1641dc3b20fd3666c1d24ea91aa7f3 -2026-06-19-acp-snapshot-tests.zh.md: 76f721d78850aff837f9851ffa11ae21cabf4488 +2026-06-19-acp-snapshot-tests.md: d6fd6f74342fda3e1f3c686376f521bf82546e85 +2026-06-19-acp-snapshot-tests.zh.md: dfd09f9d9036763c5fd98393f078bf1772aa0beb diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md index da02393fef..d6fd6f7434 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md @@ -20,7 +20,7 @@ The [session-log snapshot corpus decision](2026-08-24-session-log-snapshot-corpu Each scenario's selected highest parent generation is harvested from a real run: `session.jsonl` for v0 or `session.vN.jsonl` for a positive generation. The compact streams embedded in `assistant/message` and `assistant/attempt` reproduce model attempts; tool, message, and boundary events capture the harness behavior. One ordinary Session generation therefore serves as both replay source and behavioral expected output. -Every current v2 session-format fixture uses one physical row per durable event. Retained v0 and v1 predecessor generations may contain their frozen packed-row representation and remain immutable. Ordinary replay and log comparison prove that the assembled process selects, migrates, consumes, and reproduces the current generation. +Every current session-format fixture uses one physical row per durable event. Retained v0 and v1 predecessor generations may contain their frozen packed-row representation and remain immutable. Ordinary replay and log comparison prove that the assembled process selects, migrates, consumes, and reproduces the current generation. ### Replay derives the model script from the log diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md index 76f721d788..dfd09f9d90 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md @@ -20,7 +20,7 @@ Status: implemented 每个场景数值最高的选定 parent generation 都从真实运行中采集:v0 为 `session.jsonl`,正 generation 为 `session.vN.jsonl`。`assistant/message` 与 `assistant/attempt` 中嵌入的紧凑 stream 会复现模型 attempt;工具、message 与 boundary event 捕获 harness 行为。因此,一份普通 Session generation 同时充当 replay source 与行为预期输出。 -每个当前 v2 Session-format fixture 都为每个持久事件使用一条物理行。保留的 v0 与 v1 predecessor generation 可以包含其冻结 packed-row 表示,并保持不可变。普通 replay 与 log 比较证明组装进程会选择、迁移、消费并复现当前 generation。 +每个当前 Session-format fixture 都为每个持久事件使用一条物理行。保留的 v0 与 v1 predecessor generation 可以包含其冻结 packed-row 表示,并保持不可变。普通 replay 与 log 比较证明组装进程会选择、迁移、消费并复现当前 generation。 ### 回放从日志推导模型脚本 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml index 48e9139ebb..5e7ef2db63 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/testing/2026-07-24-web-gui-browser-e2e-lane.md -2026-07-24-web-gui-browser-e2e-lane.md: 07a37ada9c2a43f04612048f9bff6b22d022ec40 -2026-07-24-web-gui-browser-e2e-lane.zh.md: 668e612712175821d6ad123ce364a7cb96e01272 +2026-07-24-web-gui-browser-e2e-lane.md: 8276ed1982a7472ba1b825837a933058c32bf840 +2026-07-24-web-gui-browser-e2e-lane.zh.md: 3a7e5ea5547cf5584a2ad3f61b90c91326379de5 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md index 07a37ada9c..8276ed1982 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md @@ -26,6 +26,8 @@ Keyless model displacement is the disabled adapter row plus `installLlmReplay` f The barrier stack for replay-mode browser assertions is, in order: (1) host-side `await agent.whenIdle()` under a timeout, keyed off the in-process `turn/end` — the idle flip follows the persistence flush, so one await covers turn completion and durability; (2) browser settled poll (streaming detached, final text visible). Record-mode log harvest runs after `whenIdle()` and before scaffold disposal while the live session remains available. An in-process `turn/end` listener alone is a wrong barrier (it fires before the SSE frame reaches the browser and before the fsync); polling persistence files as a turn-completion or durability barrier is banned (slow on NFS, superseded by `whenIdle`), while a tool-controlled temp readiness marker may be polled only as an interaction gate before that completion barrier; `networkidle` is banned outright (never resolves while an SSE stream is open). Navigation assertions arm both initial `session.list` and `workspace.list` responses before page load, then wait for the seeded DOM projection; the mounted shell alone is not readiness because late bootstrap can replace controlled state. +Layout assertions wait for loaded fonts, completed frame transitions, and the Conversation width publication before measuring; a synthetic resize also waits for the scheduled React update before reading a portaled panel. Directory-tree reads use the same Remote-read budget for child and root listings. Workspace reload scenarios wait for restored Session selection and composer focus before opening another path editor, because that late focus can cancel its draft. Responsive file-chip scenarios keep their measured lane inside a container-query band with explicit margin for platform font metrics. + No single-shot transient-DOM assertions: every hop from replay yield to React commit can coalesce chunks, so sampling `[data-streaming]` is a race by construction. Streaming incrementality is asserted through the ordered `agent/assistant-stream` follow path, while the final durable `assistant/message` or `assistant/attempt` embeds the exact stream used for replay. `dsh-llm-replay`'s opt-in `paceMs` (default absent = burst) is a realism knob so the browser observes genuinely incremental SSE; correctness never leans on it, and abort during a pace wait cancels promptly. Pagination drivers wait for the interactive load row to leave its pending state and record the pre-request row count before scrolling. An immediately committed resident page therefore remains observable instead of becoming the baseline for a request that the scroll gesture does not repeat. diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md index 668e612712..3a7e5ea554 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md @@ -10,6 +10,8 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu ## 决策 +布局断言先等待字体加载、框架过渡完成及 Conversation 宽度发布,再读取尺寸;触发合成 resize 后,还需等待其调度的 React 更新,才能测量通过 portal 挂载的面板。目录树的子目录和根目录读取使用相同的 Remote 等待预算。工作区重载场景在打开另一个路径编辑器前,等待 Session 选择恢复及输入框获得焦点,因为迟到的聚焦会取消路径草稿。响应式文件标签场景将实测通道宽度放在容器查询档位内部,并为平台字体度量保留明确余量。 + `pnpm run test:web` 携带 `apps/web/tests/` 下的无密钥、确定性浏览器 e2e 车道:录制的会话日志 fixture 经 `@deepseek-ai/dsh-llm-replay` 对真实进程内 web 组合回放;用户可见状态使用规范化的 aria 预期输出,持久化的世界状态则使用进程内断言。配套的产品约定包括 `dsh-llm-replay` 的节奏控制、消费检查与已校验的索引式覆写 patch;跨包的 `dsh-llm` 失败通过自有数据属性保留经校验的提供方信息;已交付的 web 组合挂载 `llm-retry`,以处理瞬态模型失败。 ### Scaffold:`apps/web/tests/scaffold.ts` diff --git a/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.i18n.yaml b/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.i18n.yaml index ab338ea560..cd3854e92a 100644 --- a/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/testing/2026-09-06-backend-continuation-performance.md -2026-09-06-backend-continuation-performance.md: 62d8af10015cc2da7b399aae3c9d197f75931753 -2026-09-06-backend-continuation-performance.zh.md: 7c8904fa019b27362e2cdb0e50697921913e5d09 +2026-09-06-backend-continuation-performance.md: 94e9192df473515d39014c8be9e82e0413df47bc +2026-09-06-backend-continuation-performance.zh.md: 92556ad8e3434a3219ea585503bec9b17bbf576a diff --git a/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md b/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md index 62d8af1001..94e9192df4 100644 --- a/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md +++ b/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md @@ -25,7 +25,7 @@ The tool execution pipeline, request preparation, Session projections required b The SDK fixture explicitly inserts `fs-local` and `str_replace_editor` through its profile patch. This preserves the calibrated file-view workload independently of the [minimal profile's shell-only defaults](../simplification/2026-09-03-minimal-profiles-persistent-shell-only.md). File reads, timing endpoints, and budgets remain the same. -Five samples report raw wall time, CPU user/system time, peak RSS, endpoint counts, and the minimum, median, and maximum total wall time. Budgets enforce the unrounded median. Continuation additionally measures retained heap against an initialized Host: two explicit GCs separated by an event-loop yield precede and follow the timed operation, while the idle Agent remains reachable. The measured delta therefore includes the resident historical Session and live additions, not just newly appended turns. GC and teardown are outside timing; flush is inside. Request-history retention starts after resume and is diagnostic only. Catalog peak RSS is diagnostic; no retained-heap budget claims to measure already-released child observations. +Five samples report raw wall time, CPU user/system time, peak RSS, endpoint counts, and the minimum, median, and maximum total wall time. Each aggregate report also records CPU models, available parallelism, platform, architecture, and Node/V8 versions after the timed workers exit. Budgets enforce the unrounded median. Continuation additionally measures retained heap against an initialized Host: two explicit GCs separated by an event-loop yield precede and follow the timed operation, while the idle Agent remains reachable. The measured delta therefore includes the resident historical Session and live additions, not just newly appended turns. GC and teardown are outside timing; flush is inside. Request-history retention starts after resume and is diagnostic only. Catalog peak RSS is diagnostic; no retained-heap budget claims to measure already-released child observations. The parent bounds every child to 60 seconds, checks timeout, signal, exit, and report independently, awaits process close, and removes private roots after failures. Context and Agent teardown run in finally blocks. Seed processes cannot warm the measured process's caches. Filesystem caches are not forcibly evicted: cold means a fresh process, not cold physical storage. diff --git a/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.zh.md b/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.zh.md index 7c8904fa01..92556ad8e3 100644 --- a/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.zh.md +++ b/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.zh.md @@ -25,7 +25,7 @@ Status: implemented SDK fixture 通过 profile patch 显式插入 `fs-local` 和 `str_replace_editor`。这使经校准的文件查看负载不依赖[极简 profile 只提供 shell 的默认组合](../simplification/2026-09-03-minimal-profiles-persistent-shell-only.zh.md)。文件读取、计时终点和预算保持不变。 -五个样本报告原始壁钟时间、CPU 用户态/内核态时间、峰值 RSS、终点计数及总壁钟时间的最小值、中位数和最大值。预算约束未经舍入的中位数。续聊还相对已初始化 Host 测量保留堆内存:计时操作前后各执行两次显式 GC,中间让出一次事件循环,空闲 Agent 始终可达。因此该增量包含常驻历史 Session 和实时追加,而不只是新轮次。GC 与资源释放不计时;flush 计时。请求历史的内存基线从恢复后开始,只作诊断。目录峰值 RSS 仅作诊断;没有保留堆预算声称衡量已经释放的子会话观察。 +五个样本报告原始壁钟时间、CPU 用户态/内核态时间、峰值 RSS、终点计数及总壁钟时间的最小值、中位数和最大值。每份汇总报告还在计时 worker 退出后记录 CPU 型号、可用并行度、平台、架构以及 Node/V8 版本。预算约束未经舍入的中位数。续聊还相对已初始化 Host 测量保留堆内存:计时操作前后各执行两次显式 GC,中间让出一次事件循环,空闲 Agent 始终可达。因此该增量包含常驻历史 Session 和实时追加,而不只是新轮次。GC 与资源释放不计时;flush 计时。请求历史的内存基线从恢复后开始,只作诊断。目录峰值 RSS 仅作诊断;没有保留堆预算声称衡量已经释放的子会话观察。 父进程为每个子进程设置 60 秒上限,独立检查超时、信号、退出状态和报告,等待进程关闭,并在失败后删除私有根目录。Context 和 Agent 在 finally 中释放。播种进程无法预热被测进程的缓存。不强制清除文件系统缓存:冷指新进程,不指冷物理存储。 diff --git a/.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.i18n.yaml b/.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.i18n.yaml new file mode 100644 index 0000000000..c5a83678cc --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-08-ci-completion-observations.md +2026-09-08-ci-completion-observations.md: 8af4685a5e2c51c1edf4a41b47088916223e7a6c +2026-09-08-ci-completion-observations.zh.md: e3147bb7ac39877b46820862e9af4645803a37a2 diff --git a/.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.md b/.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.md new file mode 100644 index 0000000000..8af4685a5e --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.md @@ -0,0 +1,45 @@ +# Agent Note: CI fixture completion and isolation + +Status: implemented + +English | [中文](2026-09-08-ci-completion-observations.zh.md) + +## Problem + +The [reference CI run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34206953049) reports a webhook-created Session absent after a one-second poll and empty PowerShell output before a five-second read deadline. HTTP acceptance, projected UI state, process startup, and durable completion are separate observations. Tests need an explicit completion condition and controls that prevent an intermediate state from satisfying it. The [completion-wait decision](2026-09-08-ci-readiness-and-completion.md) owns those conditions and lane budgets; these fixtures make their ordering and cleanup observable under controlled delays. + +## Decision + +The [GitHub review browser test](../../../../apps/web/tests/github-ready-review.e2e.ts) holds real Workspace creation after HTTP 202, verifies that neither the Agent nor the model request exists, then releases creation and awaits the matching Session's `turn/end`. Cleanup releases the barrier, restores the method, and removes the event listener even when the test times out. Workspace membership, request counts, prompt content, and browser expectations retain their original assertions. + +The [PowerShell executor tests](../../../../packages/shell/pwsh-local/tests/executor.spec.ts) hold startup and consuming reads at private file barriers. The test controls when later output becomes available; final stdin/environment output is read after `done`. Polling uses the active test budget, and every constructed Context is registered before plugin initialization. Teardown captures Contexts and directories before awaiting disposal and removes directories only after that disposal completes. + +The [queued-image test](../../../../apps/web/tests/queue-image.e2e.ts) separately holds admission and attachment retrieval, then captures the admitted row's loaded thumbnail. Cleanup shares one promise, releases held requests, and drains their handlers before closing the browser. + +The [Details Session-lifecycle test](../../../../apps/web/tests/details-session-lifecycle.e2e.ts) awaits the frame's captured animation promises after closed state appears, then checks the zero-width track. Cancelled transitions also reach that assertion; animation settlement cannot make a persistent nonzero track pass. + +The [whole-queue steering test](../../../../apps/web/tests/steering.e2e.ts) waits for enabled steering actions and the composer's queue-steering hint. A model-stream barrier keeps the following question-composer takeover pending while the test observes steering. Teardown releases that barrier before browser closure. + +The [workspace-management test](../../../../apps/web/tests/workspace-management.e2e.ts) waits for restored composer focus before the next directory-dialog gesture. Its archive case gives the known seed id an explicit user title through the Session controller, then uses that exact title to identify the row across reload. An unrelated restored row cannot satisfy that locator; the durable archive assertion still checks the seed id and retained log. + +The [worker budget tests](../../../../packages/code-runtime/code-runtime-worker-thread/tests/budget.spec.ts) retain real worker execution and binding transport while controlling host timers and ELU samples. They acknowledge binding entry before exercising idle, active, and wall-clock decisions, so a bootstrap timeout cannot stand in for a budget decision during a binding. The [real-worker tests](../../../../packages/code-runtime/code-runtime-worker-thread/tests/runtime.spec.ts) independently retain actual ELU, idle-binding, and hot-loop coverage. + +The [detached-launch tests](../../../../packages/host/open-in-app/tests/launch-detached.spec.ts) control watch time and deliver late process events through the real launcher's registered callbacks. They check one settlement, one unref, and no child kill. Real-process environment and early-exit cases remain in the [resolver tests](../../../../packages/host/open-in-app/tests/resolver.spec.ts). + +The [LSP backpressure test](../../../../packages/lsp/lsp-stdio/tests/instance.spec.ts) preserves the real paused-reader fixture and large native pipe write. Before accepting the abort error, it verifies that the pending write callback settled and the captured subprocess completed; `instance.dead` alone can be true as soon as disposal starts. + +### Built-client import classification + +The [Node import sweep](../../../../packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts) admits the Dockkit bundle only when Node reports `ERR_UNKNOWN_FILE_EXTENSION` for its exact `dockkit.module.css` path. Other errors and unexpectedly successful exempt imports fail. Scoped resolve/load hooks exercise expected CSS failure, arbitrary failure, another stylesheet, another error code, and stale exemption without modifying shared build artifacts. + +## Alternatives considered + +**Production timeouts, retries, or suite serialization.** Rejected because none establishes the missing completion observation. + +**Completion inferred from acceptance or a preview.** HTTP 202 and an optimistic image can precede the operation being asserted. + +**Controlled samples replacing measured worker coverage.** Rejected because they omit verification of Node's actual ELU and transport behavior. + +## Consequences + +Each fixture owns its clocks, barriers, callbacks, processes, and temporary paths. Controlled observations supplement real worker, subprocess, browser, and persistence paths. Product behavior, production timing, benchmark budgets, CI scheduling, and recorded expectations remain unchanged. diff --git a/.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.zh.md b/.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.zh.md new file mode 100644 index 0000000000..e3147bb7ac --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.zh.md @@ -0,0 +1,45 @@ +# Agent Note: CI fixture 的完成与隔离 + +Status: implemented + +[English](2026-09-08-ci-completion-observations.md) | 中文 + +## 问题 + +[参考 CI 运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34206953049)报告:轮询一秒后 webhook 创建的 Session 仍不存在,五秒读取期限内 PowerShell 输出为空。HTTP 接受、UI 投影状态、进程启动和持久化完成是不同的观察。测试需要明确的完成条件,并用对照阻止中间状态满足该条件。[完成等待决策](2026-09-08-ci-readiness-and-completion.zh.md)拥有这些条件与 lane 预算;这些 fixture 通过受控延迟使顺序与清理可观察。 + +## 决策 + +[GitHub 评审浏览器测试](../../../../apps/web/tests/github-ready-review.e2e.ts)在 HTTP 202 后阻塞真实 Workspace 创建,验证 Agent 和模型请求均不存在,再释放创建并等待对应 Session 的 `turn/end`。即使测试超时,清理也会释放屏障、恢复方法并移除事件监听器。Workspace 归属、请求数量、提示词内容和浏览器预期保留原有断言。 + +[PowerShell 执行器测试](../../../../packages/shell/pwsh-local/tests/executor.spec.ts)用私有文件屏障控制启动与消费式读取。测试决定后续输出何时可用;最终 stdin/环境变量输出在 `done` 后读取。轮询使用当前测试预算,每个创建的 Context 都在插件初始化前登记。清理在等待释放前同时取得 Context 与目录,完成释放后才删除目录。 + +[排队图片测试](../../../../apps/web/tests/queue-image.e2e.ts)分别阻塞接纳和附件读取,再捕获已接纳行中加载完成的缩略图。清理共享一个 Promise,释放保留的请求,并在关闭浏览器前等待其 handler 完成。 + +[详情 Session 生命周期测试](../../../../apps/web/tests/details-session-lifecycle.e2e.ts)在关闭状态出现后等待框架已捕获的动画 Promise,再检查轨道宽度为零。取消的过渡同样进入该断言;动画结束不能让持续非零的轨道通过。 + +[整队列 steering 测试](../../../../apps/web/tests/steering.e2e.ts)等待 steering 操作可用以及 composer 显示队列 steering 提示。模型流屏障在测试观察 steering 时阻止后续问题 composer 接管。清理在关闭浏览器前释放该屏障。 + +[Workspace 管理测试](../../../../apps/web/tests/workspace-management.e2e.ts)在下一次目录对话框操作前等待恢复后的 composer 焦点。归档用例通过 Session controller 为已知 seed id 设置显式用户标题,再用该精确标题跨重载定位行。无关的恢复行无法匹配该定位器;持久化归档断言仍检查 seed id 和保留的日志。 + +[Worker 预算测试](../../../../packages/code-runtime/code-runtime-worker-thread/tests/budget.spec.ts)保留真实 worker 执行与绑定传输,只控制 Host 定时器和 ELU 样本。测试先确认绑定已进入,再检验 idle、active 和壁钟决策,使启动超时不能冒充绑定期间的预算决策。[真实 worker 测试](../../../../packages/code-runtime/code-runtime-worker-thread/tests/runtime.spec.ts)独立保留实际 ELU、空闲绑定和热循环覆盖。 + +[分离启动测试](../../../../packages/host/open-in-app/tests/launch-detached.spec.ts)控制观察时间,并通过真实 launcher 登记的回调发送迟到进程事件。测试检查仅完成一次、仅 unref 一次且不终止子进程。[Resolver 测试](../../../../packages/host/open-in-app/tests/resolver.spec.ts)保留真实进程的环境变量和提前退出用例。 + +[LSP 背压测试](../../../../packages/lsp/lsp-stdio/tests/instance.spec.ts)保留真实暂停读取的 fixture 与大型原生管道写入。接受 abort 错误前,测试验证待处理写入回调已完成、捕获的子进程也已结束;`instance.dead` 在释放开始时就可能为真。 + +### 已构建 Client 的导入分类 + +[Node import sweep](../../../../packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts)只有在 Node 针对准确的 `dockkit.module.css` 路径报告 `ERR_UNKNOWN_FILE_EXTENSION` 时才接受 Dockkit bundle。其他错误以及意外成功的豁免导入都会失败。限定范围的 resolve/load hook 覆盖预期 CSS 失败、任意失败、其他 stylesheet、其他错误码和过期豁免,不修改共享构建产物。 + +## 考虑过的替代方案 + +**生产超时、重试或套件串行化。** 拒绝,因为均不能建立缺少的完成观察。 + +**从接受或预览推断完成。** HTTP 202 和乐观图片可能早于被断言的操作。 + +**用受控样本替换实测 worker 覆盖。** 拒绝,因为会遗漏对 Node 实际 ELU 与传输行为的验证。 + +## 影响 + +每个 fixture 拥有自己的时钟、屏障、回调、进程和临时路径。受控观察补充真实 worker、子进程、浏览器和持久化路径。产品行为、生产时序、基准预算、CI 调度和录制预期均保持不变。 diff --git a/.github/review-ownership/README.md b/.github/review-ownership/README.md index 78d96a1f62..609b260d90 100644 --- a/.github/review-ownership/README.md +++ b/.github/review-ownership/README.md @@ -15,7 +15,7 @@ The [`weighted-approval` workflow](../workflows/weighted-approval.yml) publishes ## Approval scoring -The weighted approval workflow publishes the `weighted approval` commit status on the pull request head. Branch rules must require this status with GitHub Actions as its expected source; a context-only requirement can accept a same-named status from another integration. The status succeeds at two approval points, remains pending below two points or while the pull request is a draft, fails while a write-capable reviewer has an effective `CHANGES_REQUESTED` review, and reports an error when policy evaluation fails. +The weighted approval workflow exposes two pull-request checks. The `weighted approval publisher` Actions job reports whether evaluation and status publication completed, while the `weighted approval` commit status carries the approval decision on the pull request head. Branch rules must require only the commit status with GitHub Actions as its expected source; a context-only requirement can accept a same-named status from another integration. A completed evaluation returns `pending` below two approval points, while the pull request is a draft, or while a write-capable reviewer has an effective `CHANGES_REQUESTED` review; the blocker keeps the status pending even when counted approvals reach the threshold. It returns `success` only when the threshold is met, the pull request is ready, and no such blocker exists. If evaluation fails, the publisher writes an `error` status. Reviewers whose calculated base repository permission is `write` or `admin` count. The [approval policy](approval-policy.json) gives `@07akioni`, `@imccyu`, `@tianyicui`, `@tianyicui-bot`, `@turtle1999`, and `@turtle2099` two points each; every other write-capable reviewer gets one point. The pull-request author and reviewers without write permission do not count. diff --git a/.github/review-ownership/check-approval.mjs b/.github/review-ownership/check-approval.mjs index ac354e601f..d8e40f7731 100644 --- a/.github/review-ownership/check-approval.mjs +++ b/.github/review-ownership/check-approval.mjs @@ -8,7 +8,6 @@ const API_VERSION = '2026-03-10' const MAX_PULL_REQUEST_REVIEWS = 3_000 const PAGE_SIZE = 100 const STATUS_CONTEXT = 'weighted approval' -const STATUS_PREFIX = 'This is by automated Angry Turtle Cyborg, not a human' const WRITABLE_PERMISSIONS = new Set(['admin', 'write']) const REVIEW_STATES = new Set(['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED', 'DISMISSED', 'PENDING']) const LOGIN = /^[A-Za-z0-9-]+(?:\[bot\])?$/u @@ -130,7 +129,7 @@ export async function listPullRequestReviews(api, repository, pullNumber) { /** * Evaluate approval points from current reviews and repository permissions. * @param {{event: unknown, policySource: string, api: (path: string, options?: {method?: string, body?: unknown}) => Promise}} options Runtime inputs. - * @returns {Promise<{pull: {repository: string, number: number, headSha: string}, state: 'failure' | 'pending' | 'success', description: string, points: number, requiredPoints: number, approvals: Array<{login: string, points: number}>, blockers: string[], ignoredReviewers: string[]}>} Approval decision and status payload fields. + * @returns {Promise<{pull: {repository: string, number: number, headSha: string}, state: 'pending' | 'success', description: string, points: number, requiredPoints: number, approvals: Array<{login: string, points: number}>, blockers: string[], ignoredReviewers: string[]}>} Approval decision and status payload fields. */ export async function evaluateApproval({ event, policySource, api }) { const pull = pullRequestFromEvent(event) @@ -170,7 +169,7 @@ export async function evaluateApproval({ event, policySource, api }) { return next }, 0) if (blockers.length > 0) { - return approvalResult(pull, policy.requiredPoints, approvals, blockers, ignoredReviewers, 'failure', + return approvalResult(pull, policy.requiredPoints, approvals, blockers, ignoredReviewers, 'pending', `${blockers.length} blocking change request${blockers.length === 1 ? '' : 's'}`) } const state = points >= policy.requiredPoints ? 'success' : 'pending' @@ -192,12 +191,11 @@ export async function evaluateApproval({ event, policySource, api }) { */ export async function runApprovalCheck({ event, policySource, api, runUrl, write = line => process.stdout.write(`${line}\n`) }) { const pull = pullRequestFromEvent(event) - write(STATUS_PREFIX) let result try { result = await evaluateApproval({ event, policySource, api }) } catch (error) { - await publishStatus(api, pull, 'error', `${STATUS_PREFIX}: approval evaluation failed.`, runUrl) + await publishStatus(api, pull, 'error', 'Approval evaluation failed.', runUrl) throw error } write(`Approval score: ${result.points}/${result.requiredPoints}.`) @@ -239,7 +237,7 @@ function approvalResult(pull, requiredPoints, approvals, blockers, ignoredReview return { pull: { repository: pull.repository, number: pull.number, headSha: pull.headSha }, state, - description: `${STATUS_PREFIX}: ${detail}.`, + description: `${detail}.`, points: approvals.reduce((total, approval) => total + approval.points, 0), requiredPoints, approvals, @@ -355,7 +353,6 @@ async function main() { api, }) if (resolved === null) { - process.stdout.write(`${STATUS_PREFIX}\n`) process.stdout.write('Skipped a review event for a superseded pull-request head.\n') return } diff --git a/.github/review-ownership/check-approval.test.mjs b/.github/review-ownership/check-approval.test.mjs index 9a1412b25a..68bc9aa4bd 100644 --- a/.github/review-ownership/check-approval.test.mjs +++ b/.github/review-ownership/check-approval.test.mjs @@ -206,11 +206,13 @@ test('ignores a reviewer whose collaborator permission lookup returns 404', asyn assert.deepEqual(result.ignoredReviewers, ['former-writer']) }) -test('blocks on a write-capable change request but ignores the author and read-only blockers', async () => { - const result = await evaluateApproval({ +test('keeps the status pending on a write-capable change request while ignoring the author and read-only reviewers', async () => { + const statuses = [] + const result = await runApprovalCheck({ event: pullRequestEvent({ author: 'author' }), policySource, - api: async (path) => { + runUrl: 'https://github.example/actions/runs/1', + api: async (path, options = {}) => { if (path.includes('/reviews?')) { return [ review('turtle1999', 'APPROVED'), @@ -222,13 +224,25 @@ test('blocks on a write-capable change request but ignores the author and read-o if (path.includes('/collaborators/turtle1999/permission')) return { permission: 'admin' } if (path.includes('/collaborators/blocker/permission')) return { permission: 'write' } if (path.includes('/collaborators/reader/permission')) return { permission: 'read' } + if (path.includes('/statuses/')) { + statuses.push(options.body) + return {} + } throw new Error(`unexpected API path ${path}`) }, + write: () => {}, }) - assert.equal(result.state, 'failure') + assert.equal(result.state, 'pending') + assert.equal(result.description, '1 blocking change request.') assert.equal(result.points, 2) assert.deepEqual(result.blockers, ['blocker']) assert.deepEqual(result.ignoredReviewers, ['reader']) + assert.deepEqual(statuses, [{ + state: 'pending', + context: 'weighted approval', + description: '1 blocking change request.', + target_url: 'https://github.example/actions/runs/1', + }]) }) test('keeps drafts pending without reading reviews', async () => { @@ -266,12 +280,12 @@ test('publishes the required status and replaces stale success with error on eva body: { state: 'success', context: 'weighted approval', - description: 'This is by automated Angry Turtle Cyborg, not a human: 2/2 approval points.', + description: '2/2 approval points.', target_url: 'https://github.example/actions/runs/1', }, }, }) - assert.equal(output[0], 'This is by automated Angry Turtle Cyborg, not a human') + assert.equal(output[0], 'Approval score: 2/2.') const failures = [] await assert.rejects(runApprovalCheck({ @@ -289,6 +303,7 @@ test('publishes the required status and replaces stale success with error on eva write: () => {}, }), /reviews unavailable/u) assert.equal(failures[0].options.body.state, 'error') + assert.equal(failures[0].options.body.description, 'Approval evaluation failed.') }) test('sends authenticated JSON and escapes an API error body', async () => { diff --git a/.github/workflows/weighted-approval-review-event.yml b/.github/workflows/weighted-approval-review-event.yml index 403b8c14dd..143836a4df 100644 --- a/.github/workflows/weighted-approval-review-event.yml +++ b/.github/workflows/weighted-approval-review-event.yml @@ -14,6 +14,4 @@ jobs: timeout-minutes: 2 steps: - name: Record review event - run: | - echo 'This is by automated Angry Turtle Cyborg, not a human' - echo 'Recorded a weighted approval review event.' + run: echo 'Recorded a weighted approval review event.' diff --git a/.github/workflows/weighted-approval.yml b/.github/workflows/weighted-approval.yml index ed57b60d8d..9498d9f825 100644 --- a/.github/workflows/weighted-approval.yml +++ b/.github/workflows/weighted-approval.yml @@ -19,7 +19,7 @@ concurrency: jobs: publish-status: if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' - name: publish weighted approval status + name: weighted approval publisher runs-on: ubuntu-latest timeout-minutes: 5 steps: diff --git a/apps/web/tests/README.i18n.yaml b/apps/web/tests/README.i18n.yaml index ef09bb3c70..a45d527a95 100644 --- a/apps/web/tests/README.i18n.yaml +++ b/apps/web/tests/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/web/tests/README.md -README.md: e5b97f0b7527da01ce7c926360145fee49e168f0 -README.zh.md: f360c2b487bbf5b1a1c81492e5b4c2d4eaa8d749 +README.md: a3503e66d780e62562348b02830517381d21df92 +README.zh.md: a9039bbd0444a0786cd419c1e6aba0c3504e26d3 diff --git a/apps/web/tests/README.md b/apps/web/tests/README.md index e5b97f0b75..a3503e66d7 100644 --- a/apps/web/tests/README.md +++ b/apps/web/tests/README.md @@ -8,6 +8,10 @@ the deliberate composition divergences from `dsh web` — are documented in [`scaffold.ts`](scaffold.ts) and the [browser e2e Agent Note](../../../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md). +## Completion observations + +State-sensitive cases use Workspace, admission, attachment, and model-stream barriers to separate visible intermediate states from completed operations. Details close waits for frame transitions; archive verification assigns an explicit title to the seeded Session and follows that identity across reload. See the [CI fixture synchronization decision](../../../.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.md). + ## These are Host-face tests They type-check in the root `tsconfig.host.json`, not in the Client aggregate, diff --git a/apps/web/tests/README.zh.md b/apps/web/tests/README.zh.md index f360c2b487..a9039bbd04 100644 --- a/apps/web/tests/README.zh.md +++ b/apps/web/tests/README.zh.md @@ -7,6 +7,10 @@ [`scaffold.ts`](scaffold.ts) 和 [浏览器 e2e Agent Note](../../../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md)中。 +## 完成状态观察 + +依赖状态的用例使用 Workspace、接纳、附件和模型流屏障,区分可见中间状态与已完成操作。详情关闭等待框架过渡结束;归档验证为 seed Session 设置显式标题,并跨重载跟踪该身份。参见 [CI fixture 同步决策](../../../.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.zh.md)。 + ## 这些是 Host 面的测试 它们在根 `tsconfig.host.json` 中做类型检查,而不在 Client aggregate 中,因为它们直接读取 diff --git a/apps/web/tests/clickable-links-gallery.e2e.ts b/apps/web/tests/clickable-links-gallery.e2e.ts index ea3b86dbb5..081d183c5b 100644 --- a/apps/web/tests/clickable-links-gallery.e2e.ts +++ b/apps/web/tests/clickable-links-gallery.e2e.ts @@ -348,7 +348,7 @@ describe('web e2e: clickable links gallery', () => { const mentions = markdown.locator('code button') expect(await mentions.count()).toBe(1) expect(await mentions.first().getAttribute('title')).toBe('site/report.html') - expect(await page.getByText('Produced', { exact: true }).count()).toBe(1) + expect(await page.getByText('Files changed', { exact: true }).count()).toBe(1) expect(await page.locator('[class*="centerCol"] button[aria-label^="Open "]').count()).toBeGreaterThanOrEqual(5) expect(await page.locator('button[aria-label="Open c/broken.css"]').count()).toBe(0) diff --git a/apps/web/tests/composer-placeholder.e2e.ts b/apps/web/tests/composer-placeholder.e2e.ts new file mode 100644 index 0000000000..7fa62c29e2 --- /dev/null +++ b/apps/web/tests/composer-placeholder.e2e.ts @@ -0,0 +1,72 @@ +// The built shared Web/Electron composer hides guidance as soon as a draft contains whitespace. +import { fileURLToPath } from 'node:url' +import { chromium, type Page } from 'playwright' +import { expect, it } from 'vitest' +import { assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode } from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +it('hides the placeholder for typed and pasted spaces and restores it after deletion', async () => { + const scaffold = await launchWebScaffold({}) + try { + const browser = await chromium.launch() + let failurePage: Page | undefined + try { + const page = await newEnglishPage(browser) + failurePage = page + const tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl) + await connectFreshWorkspace(page, scaffold.workspaceCwd, 'composer-placeholder') + const input = page.locator('[data-composer-input][contenteditable="true"]').first() + const placeholder = page.locator('[data-composer-placeholder]').first() + const observations: string[] = [] + const observe = async (label: string, visible: boolean) => { + await expect.poll(() => placeholder.isVisible()).toBe(visible) + observations.push(`- ${label}: placeholder ${visible ? 'visible' : 'hidden'}`) + } + const clear = async () => { + await input.click() + await page.keyboard.press('ControlOrMeta+KeyA') + await page.keyboard.press('Backspace') + } + await observe('Empty draft', true) + await input.click() + await page.keyboard.press('Space') + await observe('Single space', false) + await page.keyboard.press('Space') + await page.keyboard.press('Space') + await observe('Consecutive spaces', false) + await page.keyboard.press('Tab') + await input.click() + await observe('Focus restored', false) + const draftMarkup = await input.innerHTML() + await page.keyboard.press('Enter') + expect(await input.innerHTML()).toBe(draftMarkup) + await observe('Whitespace submission rejected', false) + await clear() + await observe('All content deleted', true) + await page.context().grantPermissions(['clipboard-read', 'clipboard-write']) + await page.evaluate(() => navigator.clipboard.writeText(' ')) + await page.keyboard.press('ControlOrMeta+KeyV') + await expect.poll(() => input.textContent()).toBe(' ') + await observe('Pasted spaces', false) + await clear() + await observe('Pasted content deleted', true) + await assertFixtureInventory( + fileURLToPath(new URL('./expected/composer-placeholder', import.meta.url)), ['visibility.expected.md'], + ) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + await compareOrRefreshGolden( + fileURLToPath(new URL('./expected/composer-placeholder/visibility.expected.md', import.meta.url)), + observations.join('\n'), webSnapshotMode(), + ) + } catch (error) { + if (failurePage !== undefined) await saveFailureShot(failurePage, 'web-e2e-composer-placeholder') + throw error + } finally { + await browser.close() + } + } finally { + await scaffold.close() + } +}) diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts index 14a63d6e9d..14245fafa0 100644 --- a/apps/web/tests/details-session-lifecycle.e2e.ts +++ b/apps/web/tests/details-session-lifecycle.e2e.ts @@ -233,6 +233,10 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S const close = async (): Promise => { await column.locator('[data-sidebar-right-toggle]').click() await expect.poll(() => column.locator('[data-sidebar-right-open]').count()).toBe(0) + // Closing publishes state before the frame's grid transition finishes. + await appFrame(page).evaluate(async (frame) => { + await Promise.allSettled(frame.getAnimations().map(animation => animation.finished)) + }) await expect.poll(() => detailsTrack(page)).toBe(0) await panel.waitFor({ state: 'hidden' }) } @@ -279,7 +283,8 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S await workspaceDirectory.waitFor({ timeout: 15_000 }) await workspaceDirectory.click() await expect.poll(() => workspaceDirectory.getAttribute('aria-expanded')).toBe('true') - await expect.poll(() => column.locator('[data-files-row="loading"]').count()).toBe(0) + // The child listing crosses the same Remote as the root listing above. + await column.locator('[data-files-row="loading"]').waitFor({ state: 'hidden', timeout: 15_000 }) expect(await column.locator('[data-files-row="failed"]').count()).toBe(0) const retainedB = await paneSnapshot(page) expect(retainedB.map(pane => pane.tabs.map(tab => tab.title))).toEqual([['Files']]) diff --git a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md index 72096d4001..a0600f02d1 100644 --- a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md +++ b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md @@ -169,7 +169,7 @@ - list: - listitem: - paragraph: Footnote references stay inert superscripts. ↩ -- text: Produced +- text: Files changed - button "Open site/report.html": report.html - button "Open a/style.css": style.css - button "Open b/style.css": style.css diff --git a/apps/web/tests/expected/composer-placeholder/visibility.expected.md b/apps/web/tests/expected/composer-placeholder/visibility.expected.md new file mode 100644 index 0000000000..d58f7f9699 --- /dev/null +++ b/apps/web/tests/expected/composer-placeholder/visibility.expected.md @@ -0,0 +1,8 @@ +- Empty draft: placeholder visible +- Single space: placeholder hidden +- Consecutive spaces: placeholder hidden +- Focus restored: placeholder hidden +- Whitespace submission rejected: placeholder hidden +- All content deleted: placeholder visible +- Pasted spaces: placeholder hidden +- Pasted content deleted: placeholder visible diff --git a/apps/web/tests/expected/settings-chrome/plugin-instances.expected.md b/apps/web/tests/expected/settings-chrome/plugin-instances.expected.md new file mode 100644 index 0000000000..cbc7dfaa51 --- /dev/null +++ b/apps/web/tests/expected/settings-chrome/plugin-instances.expected.md @@ -0,0 +1,37 @@ +- list: + - listitem: + - button "tool-subagent-control, tool-subagent-control, 已启用": + - strong: tool-subagent-control + - text: 已启用 + - img + - code: tool-subagent-control + - listitem: + - button "tool-subagent-control/list-agents, tool-subagent-list-agents, 已启用": + - strong: tool-subagent-control/list-agents + - text: 已启用 + - img + - code: tool-subagent-list-agents + - listitem: + - button "tool-subagent, tool-subagent, 已启用": + - strong: tool-subagent + - text: 已启用 + - img + - code: tool-subagent + - listitem: + - button "tool-subagent, tool-subagent-fork, 已启用": + - strong: tool-subagent + - text: 已启用 + - img + - code: tool-subagent-fork + - listitem: + - button "tool-subagent, tool-subagent-codex, 已停用": + - strong: tool-subagent + - text: 已停用 + - img + - code: tool-subagent-codex + - listitem: + - button "tool-subagent, tool-subagent-claude-code, 已停用": + - strong: tool-subagent + - text: 已停用 + - img + - code: tool-subagent-claude-code diff --git a/apps/web/tests/expected/settings-chrome/plugins.expected.md b/apps/web/tests/expected/settings-chrome/plugins.expected.md index c4e0cbf9f0..01108aa7bc 100644 --- a/apps/web/tests/expected/settings-chrome/plugins.expected.md +++ b/apps/web/tests/expected/settings-chrome/plugins.expected.md @@ -1,6 +1,6 @@ - listitem: - - button "ui-settings, 已启用": - - strong: ui-settings - - img "运行中" + - button "tool-subagent, tool-subagent, 已启用": + - strong: tool-subagent - text: 已启用 - img + - code: tool-subagent diff --git a/apps/web/tests/feedback-release.e2e.ts b/apps/web/tests/feedback-release.e2e.ts index b63f3c6213..fdb1330697 100644 --- a/apps/web/tests/feedback-release.e2e.ts +++ b/apps/web/tests/feedback-release.e2e.ts @@ -221,14 +221,17 @@ describe.each(MODE === 'record' ? ['deepseek-official'] : ['deepseek-official', const rated = page.getByRole('button', { name: 'Remove rating' }) await expect.poll(() => rated.getAttribute('aria-pressed')).toBe('true') await expectFeedbackRelease('feedback/message-put', 1) - await page.getByRole('button', { name: 'Add a note' }).click() - await page.getByRole('textbox', { name: 'Feedback note' }).fill('Read both files before answering.') + // Dislike collects the category and note in the dialog; typing releases nothing. + await page.getByRole('button', { name: 'Bad response' }).click() + const dialog = page.getByRole('dialog', { name: 'Submit feedback' }) + await dialog.getByRole('button', { name: 'Task result', exact: true }).click() + await dialog.getByRole('textbox', { name: 'Feedback details' }).fill('Read both files before answering.') expect(captured()).toHaveLength(releasedCount) - await page.getByRole('button', { name: 'Save', exact: true }).click() - await page.getByText('Read both files before answering.', { exact: true }).waitFor() + await dialog.getByRole('button', { name: 'Submit', exact: true }).click() + await expect.poll(() => dialog.count()).toBe(0) await expectFeedbackRelease('feedback/message-put', 2) await rated.click() - await expect.poll(() => like.getAttribute('aria-pressed')).toBe('false') + await expect.poll(() => page.getByRole('button', { name: 'Bad response' }).getAttribute('aria-pressed')).toBe('false') await expectFeedbackRelease('feedback/message-delete', 1) const agent = scaffold.ctx.agents.get(sessionId) if (agent === undefined) throw new Error('feedback session has no active agent') @@ -240,7 +243,7 @@ describe.each(MODE === 'record' ? ['deepseek-official'] : ['deepseek-official', ]) expect(events.filter(event => event.type === 'feedback/message-put')).toMatchObject([ { data: { sessionId, item: { rating: 'positive' } } }, - { data: { sessionId, item: { rating: 'positive', note: 'Read both files before answering.' } } }, + { data: { sessionId, item: { rating: 'negative', note: 'Read both files before answering.', category: 'task-result' } } }, ]) expect(events.filter(event => event.type === 'feedback/message-delete')).toMatchObject([{ data: { sessionId } }]) expect(events.filter(event => event.type === 'turn/end')).toHaveLength(1) @@ -252,7 +255,9 @@ describe.each(MODE === 'record' ? ['deepseek-official'] : ['deepseek-official', const feedback = events.flatMap>((event) => { switch (event.type) { case 'feedback/record': return [{ type: event.type, text: event.data.text }] - case 'feedback/message-put': return [{ type: event.type, rating: event.data.item.rating, note: event.data.item.note }] + case 'feedback/message-put': return [{ + type: event.type, rating: event.data.item.rating, note: event.data.item.note, category: event.data.item.category, + }] case 'feedback/message-delete': return [{ type: event.type }] default: return [] } diff --git a/apps/web/tests/github-ready-review.e2e.ts b/apps/web/tests/github-ready-review.e2e.ts index fe9b498822..2c8d4b8d11 100644 --- a/apps/web/tests/github-ready-review.e2e.ts +++ b/apps/web/tests/github-ready-review.e2e.ts @@ -6,7 +6,7 @@ import type { AddressInfo } from 'node:net' import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' -import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest' +import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished, vi } from 'vitest' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { LlmAdapter } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-webhook' @@ -147,10 +147,29 @@ describe.skipIf(MODE === 'record')('web e2e: GitHub ready-for-review', () => { && event.data.source.deliveryId === 'ready' && event.data.source.ruleId === 'review-pr-when-ready') reviewSession = session.id if (event.type === 'turn/end' && session.id === reviewSession) completed.resolve(undefined) }) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + const createWorkspace = scaffold.ctx.workspaceRegistry.create.bind(scaffold.ctx.workspaceRegistry) + const create = vi.spyOn(scaffold.ctx.workspaceRegistry, 'create').mockImplementationOnce(async (...args) => { + entered.resolve(undefined) + await release.promise + return await createWorkspace(...args) + }) + onTestFinished(() => { + off() + release.resolve(undefined) + create.mockRestore() + }) try { expect((await send(webhookOrigin, 'ready', payload)).status).toBe(202) + await entered.promise + expect(scaffold.ctx.agents.list()).toHaveLength(before) + expect(adapter.requests).toHaveLength(0) + release.resolve(undefined) await completed.promise } finally { + release.resolve(undefined) + create.mockRestore() off() } expect(scaffold.ctx.agents.list()).toHaveLength(before + 1) diff --git a/apps/web/tests/message-feedback-layout.e2e.ts b/apps/web/tests/message-feedback-layout.e2e.ts deleted file mode 100644 index 1d3ee180af..0000000000 --- a/apps/web/tests/message-feedback-layout.e2e.ts +++ /dev/null @@ -1,336 +0,0 @@ -// Web e2e scenario: with the feedback note editor open, the assistant IconActions -// row stays one intact line (no wrapping, nothing pushed out), and the note -// editor floats above the transcript in a popover that escapes the conversation -// column's overflow clip and stays inside the viewport. -// -// The hazard this pins: a slot-contributed note editor (260px textarea plus -// Save and Cancel) cannot fit the shared IconActions row at ANY viewport, and an -// inline expansion made the row wider than the column — full-screen desktop -// included — so the branch action and the clock were pushed out of view by later -// flex items. The fix is to not mount the editor in the row at all: it is a -// popover portaled to document.body and fixed-positioned from the note trigger's -// rect, so the row keeps its single 28px line of icons and the trigger, and the -// panel cannot be cropped by the column's overflow because it lives outside it. -// -// The sweep records, per viewport, whether the open editor keeps the actions row -// on one line with zero overflow, whether the panel is outside the column (proof -// it escapes the clip), whether the panel stays inside the viewport (proof the -// clamp works), and whether it sits by its trigger. All relations, no absolute -// pixels: the column width follows the viewport, the sidebar, and the platform's -// scrollbar, so a golden carrying pixels would document the platform, not the -// behavior. -// -// Zero model calls: a settled transcript is cold-seeded, so nothing streams. -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 { - compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, - type WebScaffold, -} from './scaffold.ts' -import { newEnglishPage, saveFailureShot } from './support.ts' - -const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/message-feedback-layout', import.meta.url)) -/** - * Committed golden of the popover relations at every stop. Booleans and counts - * only, never absolute coordinates. - */ -const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md') -const MODE = webSnapshotMode() -/** Borrowed read-only: this scenario needs any settled assistant message to rate. */ -const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url)) -const SEED_ID = 'message-feedback-layout-e2e' -/** Viewport widths from full-screen desktop down to a narrow window. */ -const WIDTHS = [1680, 1280, 1024, 900, 700, 600] - -/** One viewport stop: how the row reads with the note editor closed and open, plus the popover's own relations. */ -export interface PopoverMetrics { - /** Viewport width the stop was measured at. */ - width: number - /** The row's scrollable overflow with the note editor closed (natural row width). */ - rowOverflowClosed: number - /** The row's scrollable overflow with the note editor open; must equal the closed value. */ - rowOverflowOpen: number - /** Flex lines the row occupies with the note editor open; the editor must not reflow it. */ - rowLines: number - /** Row items whose right edge escapes the column, editor closed. */ - itemsOutsideColumnClosed: number - /** Row items whose right edge escapes the column, editor open; must equal the closed value. */ - itemsOutsideColumnOpen: number - /** True when the portaled panel is NOT inside the column (escapes its overflow clip). */ - panelOutsideColumn: boolean - /** True when the panel lies fully inside the viewport (the clamp holds). */ - panelWithinViewport: boolean - /** Horizontal separation between the panel's left edge and the note trigger's, in px. */ - panelToTriggerGap: number -} - -/** - * Measure the feedback row (and the open popover, when present) at the current - * viewport. The same reader serves the closed and open readings so the two - * sides differ only by whether the editor is open. - * @param page - the page under test. - * @param width - the viewport width already applied, recorded with the reading. - * @param editorOpen - true to also read the popover's relations; throws if it is absent. - * @returns the stop's relations. - */ -function measurePopover(page: Page, width: number, editorOpen: boolean): Promise { - return page.evaluate(({ viewportWidth, open }) => { - const rated = document.querySelector('button[aria-label="Remove rating"]') - if (rated === null) throw new Error('no rated feedback control in the DOM') - const row = rated.parentElement?.closest('div[class*="actions"]') ?? null - if (row === null) throw new Error('the IconActions row is not an ancestor of the feedback control') - const trigger = row.querySelector('button[aria-haspopup="dialog"]') - if (trigger === null) throw new Error('the note trigger is not in the row') - - /** - * The real flex items of the row. A slot contributor (the feedback strip) - * arrives as a `display: contents` wrapper (the `assistant-actions` slot - * renders inside a transparent `data-slot` div), which reports an all-zero - * rect; a zero box would be miscounted as a phantom flex line. The actual - * items are the boxes inside it. - * @param element - the row whose items to read. - * @returns the real flex-item boxes, in flex/DOM order. - */ - const flexItemBoxes = (element: HTMLElement): DOMRect[] => { - const boxes: DOMRect[] = [] - for (const child of Array.from(element.children)) { - const el = child as HTMLElement - const rect = el.getBoundingClientRect() - if (el.style.display === 'contents') { - boxes.push(...flexItemBoxes(el)) - } else if (rect.height > 0 && rect.width > 0) { - boxes.push(rect) - } - } - return boxes - } - /** - * Group items into flex lines by overlapping vertical extent. - * @param boxes - the row items' boxes, in DOM order. - * @returns the number of distinct lines. - */ - const countFlexLines = (boxes: DOMRect[]): number => { - const centres: number[] = [] - for (const box of boxes) { - const centre = box.top + box.height / 2 - if (!centres.some(known => Math.abs(known - centre) <= box.height / 2)) centres.push(centre) - } - return centres.length - } - - const column = row.closest('[data-conversation-scroll]') - const columnRight = (column?.getBoundingClientRect().left ?? 0) + (column?.clientWidth ?? 0) - const itemRects = flexItemBoxes(row) - // A half-pixel tolerance: subpixel layout puts a contained edge a fraction - // over the boundary on some device scale factors. - const itemsOutsideColumn = itemRects.filter(box => box.right > columnRight + 0.5).length - // The editor is a portal, so the row measures identically whether the - // editor is open or not; the closed/open fields differ by call so the sweep - // can assert a zero delta on them. - const overflow = row.scrollWidth - row.clientWidth - - let builder: { - panelOutsideColumn: boolean - panelWithinViewport: boolean - panelToTriggerGap: number - } - if (!open) { - builder = { panelOutsideColumn: true, panelWithinViewport: true, panelToTriggerGap: 0 } - } else { - const panel = document.body.querySelector('[role="dialog"]') - if (panel === null) throw new Error('the note popover is not open') - const panelBox = panel.getBoundingClientRect() - const triggerBox = trigger.getBoundingClientRect() - const vw = window.innerWidth - const vh = window.innerHeight - builder = { - // The panel portals out of the column, so the clip cannot reach it. - panelOutsideColumn: column === null ? true : !column.contains(panel), - panelWithinViewport: - panelBox.left >= -0.5 - && panelBox.right <= vw + 0.5 - && panelBox.top >= -0.5 - && panelBox.bottom <= vh + 0.5, - // The panel is fixed from the trigger's left, so a zero gap says it is - // anchored; a clamp can only widen it. - panelToTriggerGap: Math.abs(panelBox.left - triggerBox.left), - } - } - - return { - width: viewportWidth, - rowOverflowClosed: overflow, - rowOverflowOpen: overflow, - rowLines: countFlexLines(itemRects), - itemsOutsideColumnClosed: itemsOutsideColumn, - itemsOutsideColumnOpen: itemsOutsideColumn, - ...builder, - } - }, { viewportWidth: width, open: editorOpen }) -} - -/** - * Render the golden body: one line per stop, relations and counts only. The - * row-overflow and outside-column readings are deltas (open minus closed) so - * the golden records that opening the editor leaves the row untouched, not an - * absolute count that many unrelated controls could move. - * @param stops - the measured stops, in sweep order. - * @returns the golden body, without a trailing newline. - */ -function renderGeometry(stops: PopoverMetrics[]): string { - return [ - '# Assistant actions row with the feedback note popover open', - '', - '| viewport | row overflow delta | row lines | items-outside delta ' - + '| panel outside the column | panel within the viewport | panel-to-trigger gap |', - '| --- | --- | --- | --- | --- | --- | --- |', - ...stops.map(stop => `| ${String(stop.width)}px | ${String(stop.rowOverflowOpen - stop.rowOverflowClosed)}px ` - + `| ${String(stop.rowLines)} | ${String(stop.itemsOutsideColumnOpen - stop.itemsOutsideColumnClosed)} ` - + `| ${String(stop.panelOutsideColumn)} | ${String(stop.panelWithinViewport)} ` - + `| ${String(stop.panelToTriggerGap)}px |`), - ].join('\n') -} - -describe('web e2e: the feedback note editor floats above the column', () => { - let scaffold: WebScaffold - let browser: Browser - let page: Page - let tripwire: ReturnType - - beforeAll(async () => { - scaffold = await launchWebScaffold({}) - await seedSession(scaffold, await readFile(SEED, 'utf8'), SEED_ID) - browser = await chromium.launch() - page = await newEnglishPage(browser, 900) - tripwire = watchConsole(page) - await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) - await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - }, 180_000) - - afterAll(async () => { - await browser?.close() - await scaffold?.close() - }) - - /** - * Open the seeded transcript. The first treeitem is the collapsible group - * row; the session itself is the row beneath it. - * @returns nothing. - */ - async function openSeededSession(): Promise { - const groupRow = page.locator('[role="treeitem"]').first() - await groupRow.waitFor({ timeout: 15_000 }) - if (await groupRow.getAttribute('aria-expanded') !== 'true') await groupRow.click() - const sessionRow = page.locator('[role="treeitem"]').nth(1) - await sessionRow.waitFor({ timeout: 15_000 }) - await sessionRow.click() - } - - /** - * Resize to a viewport and read the row once its width stops moving. The - * frame eases its column tracks, so reading straight after a resize can - * report the previous viewport's relation. - * @param width - viewport width to settle at. - * @param editorOpen - whether the note editor is currently open; reads the popover relations when so. - * @returns the row's (and popover's) readings at that width. - */ - const settleAt = async (width: number, editorOpen: boolean): Promise => { - await page.setViewportSize({ width, height: 900 }) - let previous = -1 - await expect.poll(async () => { - const current = await page.evaluate(() => - document.querySelector('[data-conversation-scroll]')?.clientWidth ?? -1) - const settled = current === previous - previous = current - return settled - }, { timeout: 10_000 }).toBe(true) - // The popover is JS-positioned from the trigger rect and re-places on - // resize/scroll, so once the column width stops moving we nudge it to the - // final layout; otherwise the panel can sit at a transient position from - // mid-resize and the anchor reading would be off. - await page.evaluate(() => window.dispatchEvent(new Event('resize'))) - return measurePopover(page, width, editorOpen) - } - - /** - * Rate a message, then for every stop read the row once with the note editor - * closed and once with it open, handing the SAME measured readings to both - * assertions so the golden and the assertions describe one measurement - * rather than two runs that could disagree. - * @returns the stops in {@link WIDTHS} order. - */ - let swept: Promise | undefined - const sweep = (): Promise => { - swept ??= (async () => { - await openSeededSession() - await page.getByText('DONE', { exact: true }).waitFor({ timeout: 30_000 }) - // The controller defers its list read to the first hover or focus, so the - // strip has to be touched before it can be rated. - const like = page.getByRole('button', { name: 'Good response' }).first() - await like.waitFor({ timeout: 30_000 }) - await like.scrollIntoViewIfNeeded() - await like.hover() - await like.click() - await page.getByRole('button', { name: 'Remove rating' }).first() - .waitFor({ timeout: 15_000 }) - const noteTrigger = page.getByRole('button', { name: 'Add a note' }).first() - const stops: PopoverMetrics[] = [] - for (const width of WIDTHS) { - // Reset to the closed baseline at each stop before opening. - if (await noteTrigger.getAttribute('aria-expanded') === 'true') await noteTrigger.click() - const closed = await settleAt(width, false) - await page.getByRole('button', { name: 'Add a note' }).first().click() - await page.getByRole('dialog').waitFor({ timeout: 10_000 }) - const open = await settleAt(width, true) - stops.push({ - width, - rowOverflowClosed: closed.rowOverflowClosed, - rowOverflowOpen: open.rowOverflowOpen, - rowLines: open.rowLines, - itemsOutsideColumnClosed: closed.itemsOutsideColumnClosed, - itemsOutsideColumnOpen: open.itemsOutsideColumnOpen, - panelOutsideColumn: open.panelOutsideColumn, - panelWithinViewport: open.panelWithinViewport, - panelToTriggerGap: open.panelToTriggerGap, - }) - } - return stops - })() - return swept - } - - it('keeps the actions row untouched by the note popover, which stays in the viewport', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-message-feedback-layout')) - const stops = await sweep() - for (const stop of stops) { - // The popover lives outside the row, so opening it must not change the - // row at all. This is the vacuity guard of the whole redesign: an inline - // editor would widen or reflow the row, pushing the delta off zero. - expect(stop.rowOverflowOpen - stop.rowOverflowClosed, `viewport ${String(stop.width)}`).toBe(0) - expect(stop.itemsOutsideColumnOpen - stop.itemsOutsideColumnClosed, `viewport ${String(stop.width)}`).toBe(0) - // The row is one 28px line; the editor never forces a reflow. - expect(stop.rowLines, `viewport ${String(stop.width)}`).toBe(1) - // The panel escapes the column's overflow clip by living outside it. - expect(stop.panelOutsideColumn, `viewport ${String(stop.width)}`).toBe(true) - // The placement clamps the panel inside the viewport at every width. - expect(stop.panelWithinViewport, `viewport ${String(stop.width)}`).toBe(true) - // The panel stays anchored to its trigger rather than drifting off. - expect(stop.panelToTriggerGap, `viewport ${String(stop.width)}`).toBeLessThanOrEqual(4) - } - expect(tripwire.pageErrors).toEqual([]) - }, 180_000) - - it('matches the committed geometry golden', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-message-feedback-layout-golden')) - await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(await sweep()), MODE) - }, 180_000) - - it('kept the console clean', () => { - expect(tripwire.pageErrors).toEqual([]) - expect(tripwire.warnings).toEqual([]) - }) -}) diff --git a/apps/web/tests/message-feedback.e2e.ts b/apps/web/tests/message-feedback.e2e.ts index 62cdb33ef2..8677beb6d9 100644 --- a/apps/web/tests/message-feedback.e2e.ts +++ b/apps/web/tests/message-feedback.e2e.ts @@ -1,11 +1,13 @@ // Keyless browser regression for durable per-message feedback. Cold-seeds a -// settled two-turn transcript (zero model calls), rates one assistant message, -// attaches a note, proves both survive a full page reload from the Host's -// message-feedback sidecar, then retracts the rating. +// settled two-turn transcript (zero model calls), likes one assistant message +// and sees the acknowledgement, replaces the Like through the Dislike dialog +// with a category and a note, proves the judgment survives a full page reload +// from the Host's canonical log, then retracts it. import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' +import { SessionId } from '@deepseek-ai/dsh-session' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { acknowledgeReloadConnectionLoss, launchWebScaffold, @@ -56,7 +58,7 @@ describe('web e2e: durable per-message feedback', () => { await sessionRow.click() } - it.skipIf(MODE === 'record')('persists a rating and its note across a reload, then retracts', async () => { + it.skipIf(MODE === 'record')('persists a Dislike with its category and note across a reload, then retracts', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-message-feedback')) await openSeededSession() @@ -69,18 +71,25 @@ describe('web e2e: durable per-message feedback', () => { await like.scrollIntoViewIfNeeded() await like.hover() await like.click() - // A recorded rating relabels the button to what the next click would do, - // so the pressed control is addressed by the retract label from here on. + // A Like records at once and is acknowledged; a recorded rating relabels + // the button to what the next click would do. + await page.getByRole('alert').filter({ hasText: 'Thanks for your feedback' }).waitFor({ timeout: 10_000 }) const rated = page.getByRole('button', { name: 'Remove rating' }).first() await expect.poll(() => rated.getAttribute('aria-pressed'), { timeout: 10_000 }).toBe('true') - // A rated message offers the note editor; an unrated one does not. - await page.getByRole('button', { name: 'Add a note' }).first().click() - const editor = page.getByRole('textbox', { name: 'Feedback note' }) - await editor.fill(NOTE) - await page.getByRole('button', { name: 'Save', exact: true }).click() - await expect.poll(() => editor.count(), { timeout: 10_000 }).toBe(0) - await page.getByText(NOTE, { exact: true }).waitFor({ timeout: 10_000 }) + // Dislike opens the Session's feedback dialog; its submission replaces + // the Like with a negative judgment carrying the category and note. + await page.getByRole('button', { name: 'Bad response' }).first().click() + const dialog = page.getByRole('dialog', { name: 'Submit feedback' }) + await dialog.waitFor({ timeout: 10_000 }) + await expect.poll(() => dialog.getByRole('textbox', { name: 'Feedback details' }).getAttribute('placeholder')) + .toBe('Add details to help us improve. Your submission will include the current conversation log.') + await dialog.getByRole('button', { name: 'Task result', exact: true }).click() + await dialog.getByRole('textbox', { name: 'Feedback details' }).fill(NOTE) + await dialog.getByRole('button', { name: 'Submit', exact: true }).click() + await expect.poll(() => dialog.count(), { timeout: 10_000 }).toBe(0) + await expect.poll(() => rated.getAttribute('aria-label'), { timeout: 10_000 }).toBe('Remove rating') + await expect.poll(() => like.getAttribute('aria-pressed'), { timeout: 10_000 }).toBe('false') // The durable assertion: a cold browser re-reads the sidecar over the wire. const warningStart = tripwire.warnings.length @@ -103,15 +112,22 @@ describe('web e2e: durable per-message feedback', () => { await restored.scrollIntoViewIfNeeded() await restored.hover() await expect.poll(() => restored.getAttribute('aria-pressed'), { timeout: 15_000 }).toBe('true') - await page.getByText(NOTE, { exact: true }).waitFor({ timeout: 10_000 }) + // The retract label sits on the Dislike side: the Like stays unpressed. + await expect.poll(() => cold.getAttribute('aria-pressed'), { timeout: 10_000 }).toBe('false') + const agent = scaffold.ctx.agents.get(SessionId(SEED_ID)) + if (agent === undefined) throw new Error('seeded session did not attach an agent') + const put = agent.session.snapshotEvents().filter(event => event.type === 'feedback/message-put').at(-1) + expect(put?.type === 'feedback/message-put' ? put.data.item : undefined) + .toMatchObject({ rating: 'negative', note: NOTE, category: 'task-result' }) // Re-clicking the active rating retracts it, and the note goes with it. await restored.click() await expect.poll( - () => page.getByRole('button', { name: 'Good response' }).first().getAttribute('aria-pressed'), + () => page.getByRole('button', { name: 'Bad response' }).first().getAttribute('aria-pressed'), { timeout: 10_000 }, ).toBe('false') - await expect.poll(() => page.getByText(NOTE, { exact: true }).count(), { timeout: 10_000 }).toBe(0) + const last = agent.session.snapshotEvents().at(-1) + expect(last?.type).toBe('feedback/message-delete') }, 90_000) it.skipIf(MODE === 'record')('kept the console clean', () => { diff --git a/apps/web/tests/present-svg.e2e.ts b/apps/web/tests/present-svg.e2e.ts new file mode 100644 index 0000000000..4ff6a5b819 --- /dev/null +++ b/apps/web/tests/present-svg.e2e.ts @@ -0,0 +1,121 @@ +/** A file request elicits explicit SVG delivery without naming the present tool. */ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium, type Browser, type Page } from 'playwright' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import type {} from '@deepseek-ai/dsh-tool-present/types' +import { deriveReplayScript, parseSessionLog } from '@deepseek-ai/dsh-llm-replay' +import { + assertFinalWorkspaceSnapshot, captureExpandedTurnProcessAria, compareOrRefreshGolden, + fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, + webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspaceZh, ZH_BROWSER_LOCALE } from './support.ts' + +const DIR = fileURLToPath(new URL('../../../snapshots/web/present-svg', import.meta.url)) +const FIXTURE = join(DIR, 'session.v3.jsonl') +const MODE = webSnapshotMode() +const PROMPT = '简单画一个 SVG 表示冯诺依曼架构, 保存为 von-neumann.svg' +const FILE = 'von-neumann.svg' + +describe('web e2e: requested SVG is explicitly delivered', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + let cwd: string + let replayRoot: string | undefined + + beforeAll(async () => { + let replayOverride: string | undefined + if (MODE !== 'record') { + replayRoot = await mkdtemp(join(tmpdir(), 'dsh-present-svg-replay-')) + replayOverride = join(replayRoot, 'replay.override.json') + const script = deriveReplayScript(parseSessionLog(await readFile(FIXTURE, 'utf8'))) + // Recorded absolute paths must follow each isolated Session's working directory. + const cwdToken = '{{fromRequest:Your working directory is ([^\\n]+)\\.}}' + await writeFile(replayOverride, JSON.stringify(script).replaceAll('{{cwd}}', JSON.stringify(cwdToken).slice(1, -1))) + } + scaffold = await launchWebScaffold({ + compareReplaySession: true, + ...(replayOverride === undefined ? {} : { replayFixture: FIXTURE, replayOverride }), + }) + browser = await chromium.launch() + page = await browser.newPage({ + viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE, timezoneId: 'Asia/Shanghai', + }) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]') + await connectFreshWorkspaceZh(page, scaffold.workspaceCwd) + }) + + afterAll(async () => { + try { + await browser?.close() + } finally { + try { + await scaffold?.close() + } finally { + if (replayRoot !== undefined) await rm(replayRoot, { recursive: true, force: true }) + } + } + }) + + it('writes valid SVG and calls present before the final reply', async () => { + if (MODE !== 'record') expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) + const settled = scaffold.whenTurnSettled() + const input = page.locator('[data-composer-input]').first() + await input.fill(PROMPT) + await input.press('Enter') + const sessionId = await settled + const session = scaffold.ctx.agents.get(sessionId)?.session + if (session?.header.cwd === undefined) throw new Error('SVG Session has no workspace') + cwd = session.header.cwd + if (MODE === 'record') await recordFixture(scaffold, sessionId, FIXTURE) + + const svg = await readFile(join(cwd, FILE), 'utf8') + const document = await page.evaluate((source) => { + const parsed = new DOMParser().parseFromString(source, 'image/svg+xml') + return { + root: parsed.documentElement.localName, + namespace: parsed.documentElement.namespaceURI, + errors: parsed.querySelectorAll('parsererror').length, + } + }, svg) + expect(document).toEqual({ root: 'svg', namespace: 'http://www.w3.org/2000/svg', errors: 0 }) + + const events = session.snapshotEvents() + const declarations = events.filter(event => event.type === 'deliverables/presented') + const delivery = declarations.find(event => event.data.files.some(file => resolve(cwd, file.path) === join(cwd, FILE))) + expect(delivery, 'the file request must produce a successful present declaration').toBeDefined() + if (delivery === undefined) throw new Error('SVG was written but not delivered') + expect(events.some(event => ( + event.type === 'tool/call' && event.data.name === 'present' && event.data.callId === delivery.data.callId + ) || ( + event.type === 'tool/ptc-dispatch' && event.data.name === 'present' + && event.data.subCallId === delivery.data.callId && !event.data.isError + ))).toBe(true) + expect(events.some(event => event.type === 'assistant/message' && event.seq > delivery.seq + && event.data.message.content.some(block => block.type === 'text'))).toBe(true) + + const card = page.locator('[data-presented-files-row]').getByRole('button').filter({ hasText: FILE }) + await card.waitFor({ state: 'visible' }) + expect(await card.count()).toBe(1) + expect(await page.getByText('产物', { exact: true }).count()).toBe(0) + if (await page.locator('[data-produced-files-row]').count() > 0) { + expect(await page.getByText('本轮文件改动', { exact: true }).count()).toBe(1) + } + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) + + it.skipIf(MODE === 'record')('replays the delivered file and Chinese conversation', async () => { + await assertFinalWorkspaceSnapshot(DIR, cwd) + // Delivery owns the transcript; navigation and composer chrome have separate scenarios. + const aria = await captureExpandedTurnProcessAria(page, '[data-chat-flow]', scaffold.workspaceCwd) + await compareOrRefreshGolden(join(DIR, 'ui.expected.md'), aria, MODE) + }) +}) diff --git a/apps/web/tests/produced-file-mentions.e2e.ts b/apps/web/tests/produced-file-mentions.e2e.ts index b8ec77c693..ea8194a9de 100644 --- a/apps/web/tests/produced-file-mentions.e2e.ts +++ b/apps/web/tests/produced-file-mentions.e2e.ts @@ -159,7 +159,7 @@ describe('web e2e: inline-code mentions of produced files', () => { expect(await mentions.first().getAttribute('aria-label')).toBe('Open site/report.html') expect(await mentions.first().getAttribute('title')).toBe('site/report.html') // The turn still ends with its produced-files row (all three writes). - expect(await page.getByText('Produced', { exact: true }).count()).toBe(1) + expect(await page.getByText('Files changed', { exact: true }).count()).toBe(1) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) diff --git a/apps/web/tests/produced-files.e2e.ts b/apps/web/tests/produced-files.e2e.ts index 1097ed3b1e..38628b0e8c 100644 --- a/apps/web/tests/produced-files.e2e.ts +++ b/apps/web/tests/produced-files.e2e.ts @@ -146,16 +146,30 @@ describe('web e2e: a finished turn ends with the files it produced', () => { await expect.poll(() => chips.count()).toBe(6) await expect.poll(() => row.getByText('+ 4 files', { exact: true }).isVisible()).toBe(true) - await page.setViewportSize({ width: 780, height: 900 }) - await expect.poll(() => chips.count()).toBe(5) + await page.setViewportSize({ width: 750, height: 900 }) + await page.evaluate(async () => { await document.fonts.ready }) + await page.waitForFunction(() => { + const frame = document.querySelector('[data-sidebar-collapsed][data-rightbar-collapsed]') + if (frame === null) return false + const tracks = getComputedStyle(frame).gridTemplateColumns.split(' ').map(Number.parseFloat) + // The responsive sidebar's settled collapsed track is 56px. + return tracks[0] === 56 && tracks.at(-1) === 0 + && frame.getAnimations().every(animation => + animation.playState === 'finished' || animation.playState === 'idle') + }, undefined, { timeout: 10_000 }) + await expect.poll(() => chips.count()).toBe(4) + const laneWidth = await row.evaluate(element => element.clientWidth) + // Keep font-metric differences away from the 479px and 583px container-query edges. + expect(laneWidth).toBeGreaterThan(503) + expect(laneWidth).toBeLessThan(559) expect(await chips.nth(0).innerText()).toBe('关于我.md') expect(await chips.nth(1).innerText()).toBe('index.html') - expect(await chips.nth(4).innerText()).toBe('app.ts') - await expect.poll(() => row.getByText('+ 5 files', { exact: true }).isVisible()).toBe(true) + expect(await chips.nth(3).innerText()).toBe('styles.css') + await expect.poll(() => row.getByText('+ 6 files', { exact: true }).isVisible()).toBe(true) // Chips open in the right Sidebar's text preview, and a directory is not // something that preview can show, so the row offers no folder action. expect(await page.getByRole('button', { name: /folder/i }).count()).toBe(0) - expect(await page.getByText('Produced', { exact: true }).count()).toBe(1) + expect(await page.getByText('Files changed', { exact: true }).count()).toBe(1) const tops = await row.locator(':scope > *:visible').evaluateAll(elements => elements.map(element => element.getBoundingClientRect().top)) diff --git a/apps/web/tests/queue-image.e2e.ts b/apps/web/tests/queue-image.e2e.ts index c78d26f013..502bb0d7c9 100644 --- a/apps/web/tests/queue-image.e2e.ts +++ b/apps/web/tests/queue-image.e2e.ts @@ -46,9 +46,12 @@ describe('web e2e: queued image submission', () => { let browser: Browser | undefined let page: Page let overrideDir: string | undefined + let cleanupRoutes: (() => Promise) | undefined afterEach(async () => { const failures: unknown[] = [] + await cleanupRoutes?.().catch((error: unknown) => failures.push(error)) + cleanupRoutes = undefined await browser?.close().catch((error: unknown) => failures.push(error)) browser = undefined const closing = scaffold @@ -97,18 +100,49 @@ describe('web e2e: queued image submission', () => { await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor({ timeout: 10_000 }) await pasteImage(page, await readFile(PNG)) await page.getByRole('img', { name: 'queued.png' }).waitFor({ timeout: 10_000 }) - await input.fill(QUEUED_TEXT) - await input.press('Enter') - - // Admission replaces the local preview; the durable row loads its own thumbnail. - await page.getByRole('button', { name: 'Remove queued message', disabled: false }).waitFor({ timeout: 15_000 }) - const dockThumb = page.locator('[data-queue-dock] li:not([data-submission-echo]) img[alt="Queued message image"]') - await dockThumb.waitFor({ timeout: 15_000 }) - await expect.poll(() => dockThumb.getAttribute('src')).toMatch(/^blob:/) - await expect.poll(() => dockThumb.evaluate((image: HTMLImageElement) => image.complete && image.naturalWidth > 0)).toBe(true) - await page.getByText(QUEUED_TEXT, { exact: true }).waitFor() - const queuedSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) - await compareOrRefreshGolden(QUEUED_EXPECTED, queuedSnapshot, MODE) + const releasePrompt = Promise.withResolvers() + const releaseImage = Promise.withResolvers() + let cleanupPromise: Promise | undefined + const cleanup = (): Promise => cleanupPromise ??= (async () => { + releasePrompt.resolve(undefined) + releaseImage.resolve(undefined) + await page.unrouteAll({ behavior: 'wait' }) + })() + cleanupRoutes = cleanup + let imageRequested = false + await page.route('**/api/session/prompt', async (route) => { + await releasePrompt.promise + await route.continue() + }) + await page.route('**/api/session/attachment', async (route) => { + imageRequested = true + await releaseImage.promise + await route.continue() + }) + const dockThumb = page.locator('[data-queue-dock] img[alt="Queued message image"]') + try { + await input.fill(QUEUED_TEXT) + await input.press('Enter') + await dockThumb.waitFor({ timeout: 15_000 }) + await expect.poll(() => dockThumb.getAttribute('src'), { timeout: 15_000 }).toMatch(/^blob:/) + expect(await page.locator('[data-queue-dock] [data-submission-echo]').count()).toBe(1) + releasePrompt.resolve(undefined) + await expect.poll(() => imageRequested, { timeout: 15_000 }).toBe(true) + await page.getByText(QUEUED_TEXT, { exact: true }).waitFor() + await page.getByRole('button', { name: 'Remove queued message', disabled: false }).waitFor({ timeout: 15_000 }) + expect(await page.locator('[data-queue-dock] [data-submission-echo]').count()).toBe(0) + expect(await dockThumb.count()).toBe(0) + releaseImage.resolve(undefined) + // Admission replaces the optimistic image; wait for the durable row's own thumbnail. + const durableThumb = page.locator('[data-queue-dock] li:not([data-submission-echo]) img[alt="Queued message image"]') + await durableThumb.waitFor({ timeout: 15_000 }) + await expect.poll(() => durableThumb.getAttribute('src'), { timeout: 15_000 }).toMatch(/^blob:/) + await expect.poll(() => durableThumb.evaluate((image: HTMLImageElement) => image.complete && image.naturalWidth > 0)).toBe(true) + const queuedSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(QUEUED_EXPECTED, queuedSnapshot, MODE) + } finally { + await cleanup() + } // Stop parks the accepted queue; the next waking send delivers the image // message first (FIFO), then its own text as the following turn. diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 55d64f199f..09e24aa7be 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -24,9 +24,10 @@ import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/settings-chrome', import.meta.url)) const DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'dialog.expected.md') const PLUGINS_EXPECTED = join(SNAPSHOT_DIR, 'plugins.expected.md') +const PLUGIN_INSTANCES_EXPECTED = join(SNAPSHOT_DIR, 'plugin-instances.expected.md') // The English fallback surface: a browser naming no shipped language. const DIALOG_EN_EXPECTED = join(SNAPSHOT_DIR, 'dialog-en.expected.md') -const PLUGIN_ROW_SELECTOR = '[data-plugin-entry$="ui-settings"]' +const PLUGIN_ROW_SELECTOR = '[data-plugin-scope="preset"] [data-plugin-entry="tool-subagent"]' const MODE = webSnapshotMode() describe('web e2e: settings modal and General preferences', () => { @@ -114,7 +115,8 @@ describe('web e2e: settings modal and General preferences', () => { const expectedPluginCount = [...scaffold.ctx.loader.entries()] .filter(entry => !entry.options.group) .length - expect(await dialog.getByRole('searchbox', { name: '搜索插件' }).count()).toBe(1) + const pluginSearch = dialog.getByRole('searchbox', { name: '搜索插件' }) + expect(await pluginSearch.count()).toBe(1) // Every Loader entry appears exactly once in the global group — rows the // presets took over included, preset compositions excluded. expect(await dialog.locator('[data-plugin-scope="global"] [data-plugin-entry]').count()) @@ -130,6 +132,35 @@ describe('web e2e: settings modal and General preferences', () => { scaffold.workspaceCwd, ) await compareOrRefreshGolden(PLUGINS_EXPECTED, pluginsSnapshot, MODE) + await pluginSearch.fill('tool-subagent') + const instanceRows = [ + ['tool-subagent', '已启用'], + ['tool-subagent-fork', '已启用'], + ['tool-subagent-codex', '已停用'], + ['tool-subagent-claude-code', '已停用'], + ] as const + for (const [entryId, status] of instanceRows) { + const row = dialog.locator(`[data-plugin-scope="preset"] [data-plugin-entry="${entryId}"]`) + const trigger = row.getByRole('button', { name: `tool-subagent, ${entryId}, ${status}`, exact: true }) + await trigger.waitFor({ timeout: 10_000 }) + expect(await trigger.getAttribute('aria-expanded')).toBe('false') + const identity = row.locator('code') + expect(await identity.textContent()).toBe(entryId) + expect(await identity.getAttribute('title')).toBe(entryId) + } + const instancesSnapshot = await captureStableAria( + page, + '[data-plugin-scope="preset"] ul', + scaffold.workspaceCwd, + ) + await compareOrRefreshGolden(PLUGIN_INSTANCES_EXPECTED, instancesSnapshot, MODE) + await dialog.getByRole('button', { + name: 'tool-subagent, tool-subagent-claude-code, 已停用', + exact: true, + }).click() + expect(await dialog.locator('[data-plugin-entry="tool-subagent-claude-code"] button') + .getAttribute('aria-expanded')).toBe('true') + await pluginSearch.fill('') // Close path 1: Escape. await page.keyboard.press('Escape') await expect.poll(() => page.getByRole('dialog', { name: '设置' }).count(), { timeout: 5_000 }).toBe(0) @@ -669,6 +700,11 @@ describe('web e2e: settings modal and General preferences', () => { it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { expect(tripwire.warnings).toEqual([]) - await assertFixtureInventory(SNAPSHOT_DIR, ['dialog-en.expected.md', 'dialog.expected.md', 'plugins.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, [ + 'dialog-en.expected.md', + 'dialog.expected.md', + 'plugin-instances.expected.md', + 'plugins.expected.md', + ]) }) }) diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index 0f7027be11..38c9bf71cf 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -312,6 +312,8 @@ describe('web e2e: composer shortcut follows the swapped busy behavior', () => { }) describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => { + const releaseReplay = Promise.withResolvers() + let disposeReplayBarrier: (() => void) | undefined let scaffold: WebScaffold let browser: Browser let page: Page @@ -327,6 +329,10 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => { replayOverride: STEER_ALL_OVERRIDE, paceMs: REPLAY_PACE_MS, }) + disposeReplayBarrier = scaffold.ctx.on('llm/stream', async function* (_options, next) { + await releaseReplay.promise + yield* next() + }, { prepend: true }) scaffold.ctx.on('session/event', (_session, event) => { sessionEvents.push(event) }) browser = await chromium.launch() page = await newEnglishPage(browser) @@ -338,6 +344,8 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => { }, 120_000) afterAll(async () => { + releaseReplay.resolve(undefined) + disposeReplayBarrier?.() await browser?.close() await scaffold?.close() }) @@ -348,8 +356,8 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => { await input.waitFor({ timeout: 10_000 }) const settled = scaffold.whenTurnSettled(30_000) - // Call 0 streams a question-tool call; the fills must land inside the - // first replay window, before the question composer replaces the textarea. + // Hold the question-tool stream until both rows have been steered, so + // question-composer takeover cannot race queue publication or the shortcut. await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor({ timeout: 10_000 }) await input.fill(PROMPT) await input.press('Enter') @@ -369,6 +377,14 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => { await dock.getByText(STEER_TWO, { exact: true }).waitFor({ timeout: 10_000 }) expect(await page.locator('[data-pending-steering]').count()).toBe(0) + // Submission echoes carry the same text before the Host queue publishes. + await expect.poll( + () => dock.getByRole('button', { name: 'Steer queued message', disabled: false }).count(), + { timeout: 10_000 }, + ).toBe(2) + await page.getByRole('textbox', { name: 'Cmd/Ctrl+Enter steers all queued messages', exact: true }) + .waitFor({ timeout: 10_000 }) + // Empty draft + Cmd+Enter: both queued rows steer in FIFO order, the dock // empties, and the pending steering renders at the conversation tail. await input.press('Meta+Enter') @@ -376,6 +392,7 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => { () => page.locator('[data-pending-steering]').filter({ hasText: /BANANA|ORANGE/ }).count(), { timeout: 10_000 }, ).toBe(2) + releaseReplay.resolve(undefined) expect(await page.locator('[data-queue-dock]').count()).toBe(0) // The reasoning row streams independently of the steering handoff. Wait // for the block to settle so the mid snapshot does not race its transient diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index 4163b8058a..a4021670fa 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -210,6 +210,10 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) acknowledgeReloadConnectionLoss(tripwire, warningStart) await expect.poll(() => page.getByText('gamma-ws', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1) + // Session restoration focuses the composer; the Workspace list can arrive + // first. Do not let that focus cancel the next directory dialog's path draft. + const composer = page.locator('[data-composer-input][contenteditable="true"]') + await expect.poll(() => composer.evaluate(element => document.activeElement === element), { timeout: 10_000 }).toBe(true) expect(tripwire.pageErrors).toEqual([]) }, 90_000) @@ -589,21 +593,28 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff it('archives the seeded session from its row menu, hiding it durably across reload', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-archive')) - const sessionRow = await seededSessionRow() + const initialRow = await seededSessionRow() // Selecting the seed hides any blank stray left by Workspace deletion, // so archiving this last visible Ungrouped Session must remove the bucket. - await sessionRow.click() + await initialRow.click() + const { title } = await scaffold.ctx.sessionController.rename({ + sessionId: SessionId(SEED_ID), title: `Archive target ${SEED_ID}`, + }) + // A user-owned title binds the locator to this seed across restoration. + const sessionRow = page.getByRole('treeitem').filter({ + has: page.getByText(title, { exact: true }), + }) + await expect.poll(() => sessionRow.count(), { timeout: 10_000 }).toBe(1) await expect.poll(() => sessionRow.getAttribute('aria-selected'), { timeout: 10_000 }).toBe('true') const ungroupedSection = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..').locator('..') await expect.poll(() => ungroupedSection.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBe(2) - const rowTitle = await sessionRow.locator('[class*="title"]').innerText() // Row menu: hover reveals the actions button; Archive session commits // without a confirmation dialog (non-destructive: log + accounting stay). - await clickHoverAction(sessionRow, `Session actions for ${rowTitle}`) + await clickHoverAction(sessionRow, `Session actions for ${title}`) await page.getByRole('menuitem', { name: 'Archive session' }).click() // The row disappears on the archive-set echo; with no other visible // stray, the whole Ungrouped bucket withdraws. - await expect.poll(() => page.getByText(rowTitle, { exact: true }).count(), { timeout: 10_000 }).toBe(0) + await expect.poll(() => sessionRow.count(), { timeout: 10_000 }).toBe(0) await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 10_000 }).toBe(0) // Durable on the host: the registry-global set carries the id while the // session log itself stays in persistence untouched. @@ -615,10 +626,18 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) acknowledgeReloadConnectionLoss(tripwire, warningStart) await expect.poll(() => page.getByText('Workspaces', { exact: true }).count(), { timeout: 15_000 }).toBe(1) + // Initial Workspace reconnection can focus the composer after the tree renders. + // Finish that navigation before the next test opens a path editor. + await page.locator('[role="treeitem"][aria-selected="true"]').waitFor({ timeout: 15_000 }) + await expect.poll( + () => page.locator('[data-composer-input][contenteditable="true"]') + .evaluate(element => element === document.activeElement), + { timeout: 15_000 }, + ).toBe(true) // The archived row must not resurface (the Ungrouped bucket itself may // reappear if selection restore lands on another stray — not this test's // concern). - expect(await page.getByText(rowTitle, { exact: true }).count()).toBe(0) + expect(await sessionRow.count()).toBe(0) expect(tripwire.pageErrors).toEqual([]) }, 90_000) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 8001c0a9c3..3b01e3990c 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -64,14 +64,15 @@ "tests/conversation-column-overflow.e2e.ts", "tests/ptc-round.e2e.ts", "tests/present.e2e.ts", + "tests/present-svg.e2e.ts", "tests/composer-draft-scroll.e2e.ts", + "tests/composer-placeholder.e2e.ts", "tests/cordis-tool-round.e2e.ts", "tests/web-search-round.e2e.ts", "tests/file-upload-round.e2e.ts", "tests/message-actions.e2e.ts", "tests/open-in-app-ssh.e2e.ts", "tests/message-feedback.e2e.ts", - "tests/message-feedback-layout.e2e.ts", "tests/markdown-images.e2e.ts", "tests/reference-composer.e2e.ts", "tests/markdown-wide-table.e2e.ts", diff --git a/benchmarks/agent-continuation/README.i18n.yaml b/benchmarks/agent-continuation/README.i18n.yaml index 096dc4d9fc..02feea1eb1 100644 --- a/benchmarks/agent-continuation/README.i18n.yaml +++ b/benchmarks/agent-continuation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write benchmarks/agent-continuation/README.md -README.md: 3d38f008c4ee95c794e4e7fbd3d874d1d668b1ce -README.zh.md: 189dcae8eea8716dbcb24b1fe2b08f1113caf36d +README.md: 489939499af8d98922df2cbbdd6be793bf6e3796 +README.zh.md: e99760cfd1aba0ca41e78243f333fe18446474fe diff --git a/benchmarks/agent-continuation/README.md b/benchmarks/agent-continuation/README.md index 3d38f008c4..489939499a 100644 --- a/benchmarks/agent-continuation/README.md +++ b/benchmarks/agent-continuation/README.md @@ -18,7 +18,7 @@ Measure long-history request processing, cold tool-heavy continuation, and repea From the repository root, build the libraries and workers with `pnpm run build:bench`, then run `pnpm exec vitest run --config vitest.bench.config.ts benchmarks/agent-continuation/agent-continuation.bench.ts`. Do not overlap timing runs with builds or other benchmarks. -The test reports all five fresh-process samples and enforces reviewed median budgets. Catalog and tool continuation each use a 900 ms standard hosted CI expectation with 1.25× headroom (1,125 ms); request history uses a separately reviewed 297 ms hosted limit ([calibration](../../.agents/notes/implemented/simplification/2026-09-06-agent-request-freeze-provenance.md)), and SDK continuation uses reference-machine scaling. A failed worker reports its exit, signal, timeout, and stderr; temporary roots are removed even on failure. The required benchmark lane discovers this file automatically. +The test reports all five fresh-process samples, CPU models, available parallelism, platform/architecture, and Node/V8 versions, and enforces reviewed median budgets. Catalog and tool continuation each use a 900 ms standard hosted CI expectation with 1.25× headroom (1,125 ms); request history uses a separately reviewed 297 ms hosted limit ([calibration](../../.agents/notes/implemented/simplification/2026-09-06-agent-request-freeze-provenance.md)), and SDK continuation uses reference-machine scaling. A failed worker reports its exit, signal, timeout, and stderr; temporary roots are removed even on failure. The required benchmark lane discovers this file automatically. diff --git a/benchmarks/agent-continuation/README.zh.md b/benchmarks/agent-continuation/README.zh.md index 189dcae8ee..e99760cfd1 100644 --- a/benchmarks/agent-continuation/README.zh.md +++ b/benchmarks/agent-continuation/README.zh.md @@ -18,7 +18,7 @@ 在仓库根目录使用 `pnpm run build:bench` 构建库和 worker,然后运行 `pnpm exec vitest run --config vitest.bench.config.ts benchmarks/agent-continuation/agent-continuation.bench.ts`。不要让计时运行与构建或其他基准重叠。 -测试报告全部五个新进程样本,并约束经审查的中位数预算。目录和工具续聊用例均使用标准托管 CI 的 900 ms 期望值与 1.25× 余量(1,125 ms);请求历史使用单独审查的 297 ms 托管上限([校准依据](../../.agents/notes/implemented/simplification/2026-09-06-agent-request-freeze-provenance.zh.md)),SDK 续聊使用参考机器缩放。worker 失败时报告退出状态、信号、超时和 stderr;失败时也会删除临时根目录。必需基准通道自动发现此文件。 +测试报告全部五个新进程样本、CPU 型号、可用并行度、平台/架构和 Node/V8 版本,并约束经审查的中位数预算。目录和工具续聊用例均使用标准托管 CI 的 900 ms 期望值与 1.25× 余量(1,125 ms);请求历史使用单独审查的 297 ms 托管上限([校准依据](../../.agents/notes/implemented/simplification/2026-09-06-agent-request-freeze-provenance.zh.md)),SDK 续聊使用参考机器缩放。worker 失败时报告退出状态、信号、超时和 stderr;失败时也会删除临时根目录。必需基准通道自动发现此文件。 diff --git a/benchmarks/agent-continuation/agent-continuation.bench.ts b/benchmarks/agent-continuation/agent-continuation.bench.ts index d1d4c0344f..0377bd6789 100644 --- a/benchmarks/agent-continuation/agent-continuation.bench.ts +++ b/benchmarks/agent-continuation/agent-continuation.bench.ts @@ -1,7 +1,7 @@ /** Baseline budgets for long-history requests, tool continuation, and fork-child discovery. */ import { cp, mkdir, mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' +import { availableParallelism, cpus, tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { runBuiltBenchmarkWorker } from '../support/built-worker.ts' @@ -166,6 +166,12 @@ describe('continuing tool-heavy Sessions with large histories', () => { const retainedHeapBudgetMb = EXPECTED_RETAINED_HEAP_MB * PERFORMANCE_BUDGET_HEADROOM console.log(JSON.stringify({ benchmark: 'agent-continuation/' + scenario, workload: WORKLOAD, + runtime: { + cpuModels: [...new Set(cpus().map(cpu => cpu.model))], + availableParallelism: availableParallelism(), + platform: process.platform, arch: process.arch, + node: process.version, v8: process.versions.v8, + }, samples, totalMs: { min: Math.min(...totalMs), median: median(totalMs), max: Math.max(...totalMs) }, budgetMs, ...(scenario === 'tool-continuation' ? { retainedHeapBudgetMb } : {}), })) diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index c370fbef7f..ab602f097d 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/capability-seams.md -capability-seams.md: a47ec285e248c4d969e49bc941d5c93faef7b65e -capability-seams.zh.md: 42e7105611f21f74ee0d8088e06e5c7007af9376 +capability-seams.md: 9cc7a6fc50b81e9f82d58600c84d1ff6494634c9 +capability-seams.zh.md: e898bdf3c2c728a1bae427bbb737eb5269a1c500 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index a47ec285e2..9cc7a6fc50 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -82,6 +82,8 @@ flowchart LR svc_storageDomain["ctx.storageDomain
Domain data facility"] pkg_workspace["workspace"] svc_messageFeedback["ctx.messageFeedback
Lifecycle-bound message feedback"] + pkg_command_feedback["command-feedback"] + svc_sessionFeedback["ctx.sessionFeedback
Session-level feedback recorder"] svc_workspaceRegistry["ctx.workspaceRegistry
Workspace entity registry"] svc_sessionQuery["ctx.sessionQuery
Session reads, traces, filters, and search"] pkg_session_reference["session-reference"] @@ -244,6 +246,7 @@ flowchart LR pkg_client_modules --> svc_clientModules pkg_code_runtime --> svc_codeRuntime pkg_code_runtime_worker_thread --> svc_codeRuntime + pkg_command_feedback --> svc_sessionFeedback pkg_commands --> svc_commands pkg_compaction --> svc_compaction pkg_compaction_basic --> svc_compaction @@ -498,6 +501,7 @@ flowchart LR | `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. | | `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. | | `ctx.messageFeedback` | `core` | [`message-feedback`](../packages/feedback/message-feedback) | - | - | - | Owns per-assistant-message feedback in the canonical Session log, target validation, per-item compare-and-set, and the Host unary Remote contract. Feedback stays outside model history; log export follows the consumer policy. | +| `ctx.sessionFeedback` | `core` | [`command-feedback`](../packages/feedback/command-feedback) | - | - | - | Records one Session-level remark with its category as a log-only feedback/record event on a live Session through the Host unary Remote contract; the /feedback command shares the same producer. | | `ctx.workspaceRegistry` | `core` | [`workspace`](../packages/workspace/workspace) | - | [`api-workspace-controller`](../packages/api/workspace-controller), [`api-session-controller`](../packages/api/session-controller) | - | Owns WorkspaceId-branded records over the domain facility; stable sessionIds accounts drive Host RPC and GUI projections. | | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference), [`tool-session-query`](../packages/session-query/tool-session-query) | - | The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations, while the model consumer owns workspace authority and cursor-free rendering. | | `ctx.fileReferences` | `seam` | [`file-reference`](../packages/context/file-reference) | [`file-reference-local`](../packages/context/file-reference-local) | [`api-session-controller`](../packages/api/session-controller) | - | The interface returns path-only completion candidates within an Agent cwd; providers own namespace access and ranking without reading file contents. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 42e7105611..e898bdf3c2 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -84,6 +84,8 @@ flowchart LR svc_storageDomain["ctx.storageDomain
Domain data facility"] pkg_workspace["workspace"] svc_messageFeedback["ctx.messageFeedback
Lifecycle-bound message feedback"] + pkg_command_feedback["command-feedback"] + svc_sessionFeedback["ctx.sessionFeedback
Session-level feedback recorder"] svc_workspaceRegistry["ctx.workspaceRegistry
Workspace entity registry"] svc_sessionQuery["ctx.sessionQuery
Session reads, traces, filters, and search"] pkg_session_reference["session-reference"] @@ -246,6 +248,7 @@ flowchart LR pkg_client_modules --> svc_clientModules pkg_code_runtime --> svc_codeRuntime pkg_code_runtime_worker_thread --> svc_codeRuntime + pkg_command_feedback --> svc_sessionFeedback pkg_commands --> svc_commands pkg_compaction --> svc_compaction pkg_compaction_basic --> svc_compaction @@ -500,6 +503,7 @@ flowchart LR | `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | 各后端以不同名称并列注册;数据形态(领域优先)挂载到枢纽上,并将类型化操作转换为不透明的 KV 单元原语。 | | `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | 等待所有已配置后端就绪,然后将领域形态发布为一个受生命周期约束的服务,用于类型化持久状态。 | | `ctx.messageFeedback` | `core` | [`message-feedback`](../packages/feedback/message-feedback) | - | - | - | 拥有权威 Session 日志中的逐 assistant 消息反馈、目标校验、逐条目 compare-and-set 及 Host 一元 Remote 契约。反馈不进入模型历史;日志导出遵循消费方策略。 | +| `ctx.sessionFeedback` | `core` | [`command-feedback`](../packages/feedback/command-feedback) | - | - | - | 通过 Host 一元 Remote 契约在 live Session 上把一条带分类的 Session 级评价记录为仅写日志的 feedback/record 事件;/feedback 命令共用同一个生产方。 | | `ctx.workspaceRegistry` | `core` | [`workspace`](../packages/workspace/workspace) | - | [`api-workspace-controller`](../packages/api/workspace-controller), [`api-session-controller`](../packages/api/session-controller) | - | 通过领域设施拥有带 WorkspaceId 品牌类型的记录;稳定的 sessionIds 账户驱动 Host RPC 与 GUI 投影。 | | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference), [`tool-session-query`](../packages/session-query/tool-session-query) | - | 该接口提供精确读取、过滤和追踪;具体后端还提供全文协调、排序、摘要片段和游标世代,而模型消费方负责工作区权限与不含游标的渲染。 | | `ctx.fileReferences` | `seam` | [`file-reference`](../packages/context/file-reference) | [`file-reference-local`](../packages/context/file-reference-local) | [`api-session-controller`](../packages/api/session-controller) | - | 该接口返回 Agent cwd 内仅含路径的补全候选;提供方负责命名空间访问与排序,但不读取文件内容。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index d667363888..7ac321fbbe 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 49d10cae1a3461b0f6c064feb27a825d689b3b9d -config-catalog.zh.md: 1b12c621edd2a63b04be3eda7ef2fceafceb3186 +config-catalog.md: 56aabb938b00fb0d118aab126448be677176af43 +config-catalog.zh.md: 2205f93b47a4c2d18a50cf4f58c783ce37a5d784 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 49d10cae1a..56aabb938b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1592,7 +1592,7 @@ export interface Config { } ``` -Source: [`packages/feedback/message-feedback/src/index.ts:39`](../packages/feedback/message-feedback/src/index.ts) +Source: [`packages/feedback/message-feedback/src/index.ts:40`](../packages/feedback/message-feedback/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 1b12c621ed..2205f93b47 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1594,7 +1594,7 @@ export interface Config { } ``` -来源:[`packages/feedback/message-feedback/src/index.ts:39`](../packages/feedback/message-feedback/src/index.ts) +来源:[`packages/feedback/message-feedback/src/index.ts:40`](../packages/feedback/message-feedback/src/index.ts) diff --git a/docs/cookbook/adding-a-session-format-version.i18n.yaml b/docs/cookbook/adding-a-session-format-version.i18n.yaml index a5d8e2ad3c..d0020b2d3a 100644 --- a/docs/cookbook/adding-a-session-format-version.i18n.yaml +++ b/docs/cookbook/adding-a-session-format-version.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/adding-a-session-format-version.md -adding-a-session-format-version.md: c2d3b966c4ca2780e5ddef933f57715721a9dd43 -adding-a-session-format-version.zh.md: 6bb93f5ff92d38acc538c53b0ff5c477bf71915b +adding-a-session-format-version.md: c445e53b0cb3e974581401d2bc99f2d180215933 +adding-a-session-format-version.zh.md: 3c3be794303dc7353d62c3e1eb51465942bc966d diff --git a/docs/cookbook/adding-a-session-format-version.md b/docs/cookbook/adding-a-session-format-version.md index c2d3b966c4..c445e53b0c 100644 --- a/docs/cookbook/adding-a-session-format-version.md +++ b/docs/cookbook/adding-a-session-format-version.md @@ -4,7 +4,7 @@ English | [中文](adding-a-session-format-version.zh.md) ## Summary -Use this tutorial to introduce a structural Session log version without rewriting released data. The worked example adds V3 through one V2→V3 edge, then lets independently reviewed changes extend that unreleased edge. Start with a working contributor checkout and read the [package checklist](adding-a-package.md), [format library](../../packages/session/session-format/README.md), and [released-format decision](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md). +Use this tutorial to introduce the next structural Session log version without rewriting released data. Let N be the latest released Session format, verified from release evidence and source, and N+1 the target. V3 is released and frozen; the concrete V3→V4 example assumes N=3. Start with a working contributor checkout and read the [package checklist](adding-a-package.md), [format library](../../packages/session/session-format/README.md), and [released-format decision](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md). ## Table of Contents @@ -21,20 +21,20 @@ Use this tutorial to introduce a structural Session log version without rewritin Bump the format for a structural change to headers, event envelopes, core event semantics, or surface reconstruction. Ordinary event additions do not require a bump; follow the [versioning rule](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md). Distinguish the Session format integer from package release versions, SQLite schema versions, projection-unit versions, and protocol-wrapper versions. -Use a shared `release/*` integration base, such as `release/session-log-v3`. The base change adds the V3 writer, codec, catalog wiring, identity migration, and verification. Create each independent child branch from that base and target its PR at the release branch, not another independent child's branch. Each child adds its own structural transformation, validators, consumers, and tests to the same `session-format-v2-to-v3` package. Do not introduce V4 or V5 just to represent review order. Merge reviewed children into the release branch through PRs, then validate the combined result before release. Honor release-branch force-push and deletion protections; do not force-sync it. +For the V3→V4 example, use a shared `release/*` integration base, such as `release/session-log-v4`. The base change adds the V4 writer, codec, catalog wiring, identity migration, and verification. Create each independent child branch from that base and target its PR at the release branch, not another independent child's branch. Each child adds its structural transformation, validators, consumers, and tests to the example's `session-format-v3-to-v4` package. Do not introduce V5 or V6 just to represent review order. Merge reviewed children into the release branch through PRs, then validate the combined result before release. Honor release-branch force-push and deletion protections; do not force-sync it. -Released codecs and migration semantics remain frozen. Do not amend V0→V1 or V1→V2 to implement a new V3 feature. Before V3 ships, its single incoming edge can incorporate the coordinated changes; after release, a structural change needs the next adjacent edge. +Released codecs and migration semantics, including V3 and V2→V3, remain frozen. Do not amend V0→V1, V1→V2, or V2→V3 to implement a new structural feature. Only the N→N+1 edge may incorporate coordinated changes before N+1 ships; after release, further structural changes need the next adjacent edge. -Use disposable, isolated Harness homes for unreleased integration testing. An interim V3 file already has the current version, so a later edit to V2→V3 will not migrate that file again. Re-run from unchanged historical input in a fresh test home; never repair this by rewriting a committed generation or reusing a real user's home. +Use disposable, isolated Harness homes for unreleased N+1 integration testing. An interim N+1 file already has the target writer version, so a later edit to N→N+1 will not migrate that file again. Re-run from unchanged historical input in a fresh test home; never repair this by rewriting a committed generation or reusing a real user's home. ## 2. Add an identity edge -Follow the package checklist to create a library, not a mounted plugin. An identity body conversion is only an initial wiring scaffold; the integrated [V2-to-V3 specification](../../packages/session/session-format-v2-to-v3/README.md#v2-to-v3-specification) defines the actual transformations and preservation rules. Do not treat its structural conversion as an identity edge. +To implement the example V3→V4 edge, follow the package checklist to create a library, not a mounted plugin. An identity body conversion is only an initial wiring scaffold. The released [V2-to-V3 specification](../../packages/session/session-format-v2-to-v3/README.md#v2-to-v3-specification) is an example of explicit transformations and preservation rules, not an edge to extend or treat as an identity conversion. -Declare `dsh.sessionFormatMigration` in the package manifest with `from: 2`, `to: 3`, an export path, and the exported migration, source codec, target codec, target-header validator, and target restorer. Reuse `releasedV2SessionFormatCodec` from the preceding edge and depend on that package; do not copy or redefine the released V2 codec. Export the V3 codec and validators from the new package. Add the new edge as a direct dependency of the catalog and add the workspace's TypeScript paths and project references. +In the example package manifest, declare `dsh.sessionFormatMigration` with `from: 3`, `to: 4`, an export path, and the exported migration, source codec, target codec, target-header validator, and target restorer. Reuse `releasedV3SessionFormatCodec` from the existing V2→V3 package and depend on that package; do not copy or redefine the released V3 codec. Export the example's V4 codec and validators from the new package. Add the new edge as a direct dependency of the catalog and add the workspace's TypeScript paths and project references. -Set `SESSION_FORMAT_VERSION` in [core Session types](../../packages/core/session/src/types.ts) to 3, then generate the catalog: +Set `SESSION_FORMAT_VERSION` in [core Session types](../../packages/core/session/src/types.ts) to N+1 (4 in the example) alongside the new edge declarations, then generate the catalog. The command below generates only the declared chain; it does not implement a new version: ```sh pnpm run gen-session-format-catalog @@ -49,9 +49,9 @@ Use the [Stage interfaces](../../packages/session/session-format/src/types.ts), Implement `transformEvent(event, context)`, `transformRun(run, context)`, and `finish(context)`. Emit synchronously through `context.emitEvent` or `context.emitRun`; a call can produce zero, one, or many outputs. Let a stage consume codec-owned compact runs directly, or iterate `run.expand()` without materializing an intermediate array. The caller owns scheduling, and the chain finishes upstream stages before downstream stages. -Treat the inherited cut as a logical event count, not a physical row count. Expose `headerInheritedEventCount` only when it is known before EOF; `finish` returns the exact target cut. A preceding cardinality-changing edge can make that count unavailable at construction. Derive it from validated seed markers when required, and test V0→V1→V2→V3 and V1→V2→V3 with seeded Sessions, not just direct V2 input. Never substitute zero for an unknown cut. +Treat the inherited cut as a logical event count, not a physical row count. Expose `headerInheritedEventCount` only when it is known before EOF; `finish` returns the exact target cut. A preceding cardinality-changing edge can make that count unavailable at construction. Derive it from validated seed markers when required, and test the example's V0→V1→V2→V3→V4, V1→V2→V3→V4, and V2→V3→V4 chains with seeded Sessions, not just direct V3 input. Never substitute zero for an unknown cut. -Define each edge's event admission and transformation rules explicitly; the [V2-to-V3 source audit](../../packages/session/session-format-v2-to-v3/README.md#source-audit) owns this edge's policy. The [alpha V0→V1 rule](../../.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.md) owns the preceding edge's policy. Do not generalize either to every edge. A change to structure or event positions requires classifying source events, payload members, and references, and explicitly deciding whether opaque data can remain valid. [Equal-version retention](../../.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md) alone does not prove a structural transformation safe. Validate target semantics and give each newly accepted case a rejecting counterexample; never widen older edges to hide an unsupported transformation. +Define the new edge's event admission and transformation rules explicitly. The [V2-to-V3 source audit](../../packages/session/session-format-v2-to-v3/README.md#source-audit) and [alpha V0→V1 rule](../../.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.md) own the policies of those released edges, not the new edge. Do not generalize either to every edge. A change to structure or event positions requires classifying source events, payload members, and references, and explicitly deciding whether opaque data can remain valid. [Equal-version retention](../../.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md) alone does not prove a structural transformation safe. Validate target semantics and give each newly accepted case a rejecting counterexample; never widen older edges to hide an unsupported transformation. Prove strict restoration through `sessionFormatCatalog.createRestore(header, { recovery: 'strict', validation: 'current' })`, feeding rows in order and calling `finish()`. This exercises physical decoding, the complete chain, and installed current Session validation. Production's recoverable/transformed policy is not a replacement for strict fixture and publication verification. Preserve documented historical validation exceptions rather than claiming stricter source validation than the edge actually performs. @@ -67,9 +67,9 @@ Verify both read and write paths. Header-only listing must not read bodies or pu ## 5. Create snapshot successors -Read [snapshot ownership](../../snapshots/AGENTS.md) and the [snapshot library](../../packages/test-support/session-snapshot/README.md). Select the owning scenario, not an adapter that only references it. For each role, keep the historical file and generate the current successor: `session.v3.jsonl` for the parent and `session.1.v3.jsonl`, `session.2.v3.jsonl`, and so on for children. Never rename `session.v2.jsonl` to V3 or change only its header. +Read [snapshot ownership](../../snapshots/AGENTS.md) and the [snapshot library](../../packages/test-support/session-snapshot/README.md). Select the owning scenario, not an adapter that only references it. After implementing N+1, keep each historical file and generate its successor. In the V3→V4 example, use `session.v4.jsonl` for the parent and `session.1.v4.jsonl`, `session.2.v4.jsonl`, and so on for children. Never rename `session.v3.jsonl` to V4 or change only its header. -For unchanged replay input, use keyless refresh on the owner, then replay without write-back. This concrete SDK example uses `text-turn`; select the actual affected owner for a feature: +For unchanged replay input, use keyless refresh on the owner, then replay without write-back. These SDK commands use `text-turn` and the checkout's writer version. Implement and wire N+1 before using them to generate that version, and select the actual affected owner for a feature: ```sh pnpm run test:snapshot:refresh snapshots/sdk/sdk.snapshot.ts -t text-turn @@ -83,7 +83,7 @@ Keep deliberate historical cases explicit through `snapshot.yml`'s `sessionForma ## 6. Validate the integrated result -Run from the repository root. These focused commands check catalog declarations, Stage composition, the new edge, and generation selection: +Run from the repository root. These commands check catalog declarations, Stage composition, the released V2→V3 edge, and generation selection. They are a baseline; add focused coverage for the new edge: ```sh pnpm run verify-session-format-catalog @@ -91,7 +91,7 @@ pnpm exec vitest run scripts/gen-session-format-catalog.spec.ts packages/session pnpm run test:snapshot scripts/session-snapshot-corpus.corpus.ts ``` -Add the changed JSONL, replay, projection, and SDK tests selected by the actual diff, plus the built publication-Worker smoke when that path changes. Require successful strict migration, identity preservation for the skeleton, malformed and unknown-required-event refusal, deterministic repeated restores, independent concurrent stage state, seeded multi-hop cuts, unchanged predecessors, and no fallback. Report exact commands and failures, not an inferred full-suite result. +After implementing the new edge, add its actual test path to the focused Vitest run. Add the changed JSONL, replay, projection, and SDK tests selected by the actual diff, plus the built publication-Worker smoke when that path changes. Require successful strict migration, identity preservation for the skeleton, malformed and unknown-required-event refusal, deterministic repeated restores, independent concurrent stage state, seeded multi-hop cuts, unchanged predecessors, and no fallback. Report exact commands and failures, not an inferred full-suite result. Update the [owning Agent Note](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md) rather than adding a redundant decision record. Audit related active notes for supersession; retain independent rationale and leave archived notes frozen. Update bilingual prose together, re-record each changed pair with the repository tool, then run documentation checks: diff --git a/docs/cookbook/adding-a-session-format-version.zh.md b/docs/cookbook/adding-a-session-format-version.zh.md index 6bb93f5ff9..3c3be79430 100644 --- a/docs/cookbook/adding-a-session-format-version.zh.md +++ b/docs/cookbook/adding-a-session-format-version.zh.md @@ -4,7 +4,7 @@ ## 概述 -本教程介绍如何添加结构性的 Session 日志版本,同时不改写已发布数据。示例通过单条 V2→V3 迁移边添加 V3,再让独立评审的变更扩展这条尚未发布的迁移边。开始前,请准备可用的贡献者工作区,并阅读[包检查清单](adding-a-package.zh.md)、[格式库](../../packages/session/session-format/README.zh.md)和[已发布格式决策](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md)。 +本教程介绍如何添加下一个结构性 Session 日志版本,同时不改写已发布数据。令 N 为经发布证据与源码确认的最新已发布 Session 格式,N+1 为目标版本。V3 已发布并冻结;具体的 V3→V4 示例假设 N=3。开始前,请准备可用的贡献者工作区,并阅读[包检查清单](adding-a-package.zh.md)、[格式库](../../packages/session/session-format/README.zh.md)和[已发布格式决策](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md)。 ## 目录 @@ -21,20 +21,20 @@ 当 header、事件信封、核心事件语义或表面重建发生结构性变更时,提升格式版本。普通事件新增不需要提升版本;遵循[版本规则](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md)。区分 Session 格式整数与包发布版本、SQLite schema 版本、投影单元版本及协议包装层版本。 -使用共享的 `release/*` 集成基线,例如 `release/session-log-v3`。基线变更添加 V3 写入器、codec、catalog 接线、恒等迁移与验证。从该基线创建各个独立子分支,并将其 PR(Pull Request)的目标设为发布分支,而非另一个独立子分支。每个子分支在同一个 `session-format-v2-to-v3` 包内添加自身的结构变换、校验器、消费方和测试。不要只为表示评审顺序而引入 V4 或 V5。通过 PR 将评审后的子分支合入发布分支,并在发布前验证组合结果。遵守发布分支的强制推送与删除保护;不要强制同步该分支。 +在 V3→V4 示例中,使用共享的 `release/*` 集成基线,例如 `release/session-log-v4`。基线变更添加 V4 写入器、codec、catalog 接线、恒等迁移与验证。从该基线创建各个独立子分支,并将其 PR(Pull Request)的目标设为发布分支,而非另一个独立子分支。每个子分支在示例的 `session-format-v3-to-v4` 包内添加自身的结构变换、校验器、消费方和测试。不要只为表示评审顺序而引入 V5 或 V6。通过 PR 将评审后的子分支合入发布分支,并在发布前验证组合结果。遵守发布分支的强制推送与删除保护;不要强制同步该分支。 -已发布 codec 和迁移语义保持冻结。不要通过修改 V0→V1 或 V1→V2 来实现新的 V3 功能。在 V3 发布前,其唯一入边可以纳入这些协同变更;发布后,结构性变更需要下一条相邻迁移边。 +已发布 codec 和迁移语义(包括 V3 与 V2→V3)保持冻结。不要通过修改 V0→V1、V1→V2 或 V2→V3 来实现新的结构性功能。只有 N→N+1 迁移边可在 N+1 发布前纳入协同变更;发布后,进一步的结构性变更需要下一条相邻迁移边。 -未发布版本的集成测试应使用可丢弃、相互隔离的 Harness home。中间版本产生的 V3 文件已经标为当前版本,因此后续对 V2→V3 的修改不会再次迁移该文件。请在全新测试 home 中从未变更的历史输入重新运行;绝不通过改写已提交代际或复用真实用户 home 来修复这个问题。 +未发布 N+1 的集成测试应使用可丢弃、相互隔离的 Harness home。中间版本产生的 N+1 文件已标为目标写入器版本,因此后续对 N→N+1 的修改不会再次迁移该文件。请在全新测试 home 中从未变更的历史输入重新运行;绝不通过改写已提交代际或复用真实用户 home 来修复这个问题。 ## 2. 添加恒等迁移边 -按照包检查清单创建库,而非挂载插件。恒等正文转换仅是最初的接线骨架;集成后的 [V2 到 V3 规范](../../packages/session/session-format-v2-to-v3/README.zh.md#v2-to-v3-specification)定义实际转换与保留规则。不要将其结构转换视为恒等迁移边。 +若要实现示例中的 V3→V4 迁移边,按照包检查清单创建库,而非挂载插件。恒等正文转换仅是最初的接线骨架。已发布的 [V2 到 V3 规范](../../packages/session/session-format-v2-to-v3/README.zh.md#v2-to-v3-specification)展示如何明确转换与保留规则,而不是可继续扩展或视为恒等转换的迁移边。 -在包 manifest(元数据清单)中声明 `dsh.sessionFormatMigration`,包含 `from: 2`、`to: 3`、导出路径,以及导出的迁移、源 codec、目标 codec、目标 header 校验器和目标恢复器。复用前一条迁移边的 `releasedV2SessionFormatCodec`,并依赖该包;不要复制或重新定义已发布 V2 codec。从新包导出 V3 codec 和校验器。将新迁移边加入 catalog 的直接依赖,并添加工作区的 TypeScript 路径与项目引用。 +在示例包的 manifest(元数据清单)中声明 `dsh.sessionFormatMigration`,包含 `from: 3`、`to: 4`、导出路径,以及导出的迁移、源 codec、目标 codec、目标 header 校验器和目标恢复器。复用现有 V2→V3 包的 `releasedV3SessionFormatCodec`,并依赖该包;不要复制或重新定义已发布 V3 codec。从新包导出示例的 V4 codec 和校验器。将新迁移边加入 catalog 的直接依赖,并添加工作区的 TypeScript 路径与项目引用。 -将[核心 Session 类型](../../packages/core/session/src/types.ts)中的 `SESSION_FORMAT_VERSION` 设为 3,然后生成 catalog: +在添加新迁移边声明的同时,将[核心 Session 类型](../../packages/core/session/src/types.ts)中的 `SESSION_FORMAT_VERSION` 设为 N+1(示例中为 4),然后生成 catalog。下面的命令只生成已声明的迁移链;它不会实现新版本: ```sh pnpm run gen-session-format-catalog @@ -49,9 +49,9 @@ pnpm run gen-session-format-catalog 实现 `transformEvent(event, context)`、`transformRun(run, context)` 和 `finish(context)`。通过 `context.emitEvent` 或 `context.emitRun` 同步输出;一次调用可以产生零个、一个或多个输出。让 Stage 直接消费 codec 所有的紧凑 run,或者迭代 `run.expand()`,而不物化中间数组。调用方负责调度,迁移链先结束上游 Stage,再结束下游 Stage。 -继承截点是逻辑事件数量,不是物理行数。只有在 EOF 前已知时才公开 `headerInheritedEventCount`;`finish` 返回精确的目标截点。前一条改变事件数量的迁移边可能使该数量在构造时不可知。必要时从已校验的种子标记推导它,并用有种子的 Session 测试 V0→V1→V2→V3 和 V1→V2→V3,而非仅测试直接 V2 输入。绝不以零替代未知截点。 +继承截点是逻辑事件数量,不是物理行数。只有在 EOF 前已知时才公开 `headerInheritedEventCount`;`finish` 返回精确的目标截点。前一条改变事件数量的迁移边可能使该数量在构造时不可知。必要时从已校验的种子标记推导它,并用有种子的 Session 测试示例中的 V0→V1→V2→V3→V4、V1→V2→V3→V4 和 V2→V3→V4 迁移链,而非仅测试直接 V3 输入。绝不以零替代未知截点。 -显式定义每条迁移边的事件准入与变换规则;[V2 到 V3 源审计](../../packages/session/session-format-v2-to-v3/README.zh.md#source-audit)负责本迁移边的策略。[Alpha V0→V1 规则](../../.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.zh.md)负责前代迁移边的策略。不要将任一策略推广到所有迁移边。结构或事件位置变化时,必须分类源事件、载荷成员与引用,并显式判断不透明数据能否保持有效。[同版本保留](../../.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.zh.md)本身不能证明结构变换安全。校验目标语义,并为每个新增可接受案例提供一个被拒绝的反例;绝不放宽旧迁移边来掩盖不受支持的转换。 +显式定义新迁移边的事件准入与变换规则。[V2 到 V3 源审计](../../packages/session/session-format-v2-to-v3/README.zh.md#source-audit)和 [Alpha V0→V1 规则](../../.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.zh.md)分别负责对应已发布迁移边的策略,而非新迁移边的策略。不要将任一策略推广到所有迁移边。结构或事件位置变化时,必须分类源事件、载荷成员与引用,并显式判断不透明数据能否保持有效。[同版本保留](../../.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.zh.md)本身不能证明结构变换安全。校验目标语义,并为每个新增可接受案例提供一个被拒绝的反例;绝不放宽旧迁移边来掩盖不受支持的转换。 通过 `sessionFormatCatalog.createRestore(header, { recovery: 'strict', validation: 'current' })` 验证严格恢复,按顺序传入各行并调用 `finish()`。这会执行物理解码、完整迁移链与已安装当前 Session 校验。生产环境的 recoverable/transformed 策略不能替代 fixture(测试前置数据)和发布验证所需的严格校验。保留已记录的历史校验例外,不要宣称源校验比迁移边实际执行的更严格。 @@ -67,9 +67,9 @@ pnpm run gen-session-format-catalog ## 5. 创建快照后继代际 -阅读[快照所有权](../../snapshots/AGENTS.md)和[快照库](../../packages/test-support/session-snapshot/README.zh.md)。选择拥有数据的场景,而非仅引用它的适配器。为每个角色保留历史文件,并生成当前后继文件:父角色使用 `session.v3.jsonl`,子角色依次使用 `session.1.v3.jsonl`、`session.2.v3.jsonl` 等。绝不将 `session.v2.jsonl` 重命名为 V3,或仅修改其 header。 +阅读[快照所有权](../../snapshots/AGENTS.md)和[快照库](../../packages/test-support/session-snapshot/README.zh.md)。选择拥有数据的场景,而非仅引用它的适配器。实现 N+1 后,保留每份历史文件,并生成其后继文件。在 V3→V4 示例中,父角色使用 `session.v4.jsonl`,子角色依次使用 `session.1.v4.jsonl`、`session.2.v4.jsonl` 等。绝不将 `session.v3.jsonl` 重命名为 V4,或仅修改其 header。 -如果回放输入不变,在所有者上执行无密钥 refresh,再执行不写回的 replay。这个具体 SDK 示例使用 `text-turn`;功能变更应选择实际受影响的所有者: +如果回放输入不变,在所有者上执行无密钥 refresh,再执行不写回的 replay。以下 SDK 命令使用 `text-turn` 和工作区的写入器版本。先实现并接入 N+1,才能用它们生成该版本;功能变更应选择实际受影响的所有者: ```sh pnpm run test:snapshot:refresh snapshots/sdk/sdk.snapshot.ts -t text-turn @@ -83,7 +83,7 @@ pnpm run test:snapshot snapshots/sdk/sdk.snapshot.ts -t text-turn ## 6. 验证集成结果 -从仓库根目录运行。以下聚焦命令检查 catalog 声明、Stage 组合、新迁移边与代际选择: +从仓库根目录运行。以下命令检查 catalog 声明、Stage 组合、已发布的 V2→V3 迁移边与代际选择。它们是基线检查;需为新迁移边添加聚焦覆盖: ```sh pnpm run verify-session-format-catalog @@ -91,7 +91,7 @@ pnpm exec vitest run scripts/gen-session-format-catalog.spec.ts packages/session pnpm run test:snapshot scripts/session-snapshot-corpus.corpus.ts ``` -根据实际 diff 添加受影响的 JSONL、回放、投影与 SDK 测试;发布 Worker 路径变化时还需构建产物冒烟测试。要求严格迁移成功、骨架保持恒等、拒绝格式错误与未知必需事件、重复恢复确定、并发 Stage 状态独立、有种子的多跳截点正确、前代不变且无回退。报告确切命令与失败,不要推断整个测试套件的结果。 +实现新迁移边后,将其实际测试路径加入聚焦的 Vitest 命令。根据实际 diff 添加受影响的 JSONL、回放、投影与 SDK 测试;发布 Worker 路径变化时还需构建产物冒烟测试。要求严格迁移成功、骨架保持恒等、拒绝格式错误与未知必需事件、重复恢复确定、并发 Stage 状态独立、有种子的多跳截点正确、前代不变且无回退。报告确切命令与失败,不要推断整个测试套件的结果。 更新[所属 Agent Note](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md),而非添加重复决策记录。审计相关活跃记录的取代关系;保留独立理由,并保持归档记录冻结。一起更新双语正文,通过仓库工具重新记录每个变更的配对,然后运行文档检查: diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index e322f3980d..d644a3e50a 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: 15b7f7161de76b9d0d1afd253838a9fd38ec5f69 -event-producer-consumer.zh.md: e1ada29cdbb05b3e21c7b54a5fc75c004749e4cd +event-producer-consumer.md: d6c54875115617b3c67b015325335f2340a5c469 +event-producer-consumer.zh.md: 34c4ef5b738a9c637efed94f39ddae6b219023f4 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 15b7f7161d..d6c5487511 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -38,7 +38,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:102`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:90`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`credentials`](../packages/credentials/credentials), `remotes` | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace), `workspace-controller` | -| `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:57`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | +| `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:58`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:76`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index e1ada29cdb..34c4ef5b73 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -40,7 +40,7 @@ | `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:102`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:90`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`credentials`](../packages/credentials/credentials), `remotes` | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace), `workspace-controller` | -| `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:57`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | +| `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:58`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:76`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index cf4f2a6735..e6eaa903f2 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: e498227db7cc482beb2dd69aab147baec8eead22 -module-graph.zh.md: c3956f8b466f791b2b90aee12bedba7f95ff7cb9 +module-graph.md: cbaa8cb04a7ec2406b1f6e7f2c1dbf0a8dff0527 +module-graph.zh.md: 148b2730fccc1740d1c2f24d766f2aa7213093cf diff --git a/docs/module-graph.md b/docs/module-graph.md index e498227db7..cbaa8cb04a 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -474,11 +474,6 @@ flowchart TD pkg_spill_local --> pkg_spill pkg_session_log_export --> pkg_session pkg_session_log_export --> pkg_session_persistence - pkg_message_feedback --> pkg_brand - pkg_message_feedback --> pkg_llm - pkg_message_feedback --> pkg_session - pkg_message_feedback --> pkg_session_persistence - pkg_message_feedback --> pkg_typert_protocol pkg_sandbox_local --> pkg_llm pkg_sandbox_local --> pkg_sandbox pkg_sandbox_local --> pkg_session @@ -668,6 +663,7 @@ flowchart TD pkg_command_feedback --> pkg_anonymous_user_id pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_session + pkg_command_feedback --> pkg_typert_protocol pkg_permission_presets --> pkg_commands pkg_permission_presets --> pkg_invariants pkg_permission_presets --> pkg_sandbox @@ -803,6 +799,12 @@ flowchart TD pkg_cordis_host_runner --> pkg_session pkg_cordis_host_runner --> pkg_tools pkg_cordis_host_runner --> pkg_typert_protocol + pkg_message_feedback --> pkg_brand + pkg_message_feedback --> pkg_command_feedback + pkg_message_feedback --> pkg_llm + pkg_message_feedback --> pkg_session + pkg_message_feedback --> pkg_session_persistence + pkg_message_feedback --> pkg_typert_protocol pkg_repeat_tool_reminder --> pkg_agent pkg_repeat_tool_reminder --> pkg_tools pkg_tool_call_timeout_policy --> pkg_llm @@ -852,12 +854,6 @@ flowchart TD pkg_session_checkpoint_policy --> pkg_session pkg_session_checkpoint_policy --> pkg_session_persistence pkg_session_checkpoint_policy --> pkg_tools - pkg_session_telemetry_otel --> pkg_anonymous_user_id - pkg_session_telemetry_otel --> pkg_command_feedback - pkg_session_telemetry_otel --> pkg_llm - pkg_session_telemetry_otel --> pkg_message_feedback - pkg_session_telemetry_otel --> pkg_session - pkg_session_telemetry_otel --> pkg_session_telemetry pkg_session_title_all_prompts_llm --> pkg_llm pkg_session_title_all_prompts_llm --> pkg_session pkg_session_title_all_prompts_llm --> pkg_session_title @@ -937,6 +933,12 @@ flowchart TD pkg_host_plugin_inventory --> pkg_agent_presets pkg_host_plugin_inventory --> pkg_brand pkg_host_plugin_inventory --> pkg_typert_protocol + pkg_session_telemetry_otel --> pkg_anonymous_user_id + pkg_session_telemetry_otel --> pkg_command_feedback + pkg_session_telemetry_otel --> pkg_llm + pkg_session_telemetry_otel --> pkg_message_feedback + pkg_session_telemetry_otel --> pkg_session + pkg_session_telemetry_otel --> pkg_session_telemetry pkg_tool_bash --> pkg_agent pkg_tool_bash --> pkg_jobs pkg_tool_bash --> pkg_llm @@ -1321,7 +1323,6 @@ flowchart TD | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) | | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | -| [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-protocol`](../packages/typert/protocol) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | | [`session-projection-cache`](../packages/session/session-projection-cache) | `session` | [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain) | @@ -1364,7 +1365,7 @@ flowchart TD | [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`session`](../packages/core/session) | +| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`permission-presets`](../packages/interaction/permission-presets) | `interaction` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/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), [`shell`](../packages/shell/shell), [`user-approval`](../packages/interaction/user-approval) | | [`jobs-local`](../packages/jobs/jobs-local) | `jobs` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`scope`](../packages/core/scope), [`timeout`](../packages/util/timeout) | | [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) | @@ -1389,6 +1390,7 @@ flowchart TD | [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | `extensions` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | +| [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-protocol`](../packages/typert/protocol) | | [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder) | `guard` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools) | | [`tool-call-timeout-policy`](../packages/guard/timeout-policy) | `guard` | [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-ask-user`](../packages/interaction/tool-ask-user) | `interaction` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | @@ -1398,7 +1400,6 @@ flowchart TD | [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | | [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | -| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | | [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`shell-env`](../packages/shell/shell-env) | `shell` | [`home-paths`](../packages/util/home-paths), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) | @@ -1415,6 +1416,7 @@ flowchart TD | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) | +| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index c3956f8b46..148b2730fc 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -476,11 +476,6 @@ flowchart TD pkg_spill_local --> pkg_spill pkg_session_log_export --> pkg_session pkg_session_log_export --> pkg_session_persistence - pkg_message_feedback --> pkg_brand - pkg_message_feedback --> pkg_llm - pkg_message_feedback --> pkg_session - pkg_message_feedback --> pkg_session_persistence - pkg_message_feedback --> pkg_typert_protocol pkg_sandbox_local --> pkg_llm pkg_sandbox_local --> pkg_sandbox pkg_sandbox_local --> pkg_session @@ -670,6 +665,7 @@ flowchart TD pkg_command_feedback --> pkg_anonymous_user_id pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_session + pkg_command_feedback --> pkg_typert_protocol pkg_permission_presets --> pkg_commands pkg_permission_presets --> pkg_invariants pkg_permission_presets --> pkg_sandbox @@ -805,6 +801,12 @@ flowchart TD pkg_cordis_host_runner --> pkg_session pkg_cordis_host_runner --> pkg_tools pkg_cordis_host_runner --> pkg_typert_protocol + pkg_message_feedback --> pkg_brand + pkg_message_feedback --> pkg_command_feedback + pkg_message_feedback --> pkg_llm + pkg_message_feedback --> pkg_session + pkg_message_feedback --> pkg_session_persistence + pkg_message_feedback --> pkg_typert_protocol pkg_repeat_tool_reminder --> pkg_agent pkg_repeat_tool_reminder --> pkg_tools pkg_tool_call_timeout_policy --> pkg_llm @@ -854,12 +856,6 @@ flowchart TD pkg_session_checkpoint_policy --> pkg_session pkg_session_checkpoint_policy --> pkg_session_persistence pkg_session_checkpoint_policy --> pkg_tools - pkg_session_telemetry_otel --> pkg_anonymous_user_id - pkg_session_telemetry_otel --> pkg_command_feedback - pkg_session_telemetry_otel --> pkg_llm - pkg_session_telemetry_otel --> pkg_message_feedback - pkg_session_telemetry_otel --> pkg_session - pkg_session_telemetry_otel --> pkg_session_telemetry pkg_session_title_all_prompts_llm --> pkg_llm pkg_session_title_all_prompts_llm --> pkg_session pkg_session_title_all_prompts_llm --> pkg_session_title @@ -939,6 +935,12 @@ flowchart TD pkg_host_plugin_inventory --> pkg_agent_presets pkg_host_plugin_inventory --> pkg_brand pkg_host_plugin_inventory --> pkg_typert_protocol + pkg_session_telemetry_otel --> pkg_anonymous_user_id + pkg_session_telemetry_otel --> pkg_command_feedback + pkg_session_telemetry_otel --> pkg_llm + pkg_session_telemetry_otel --> pkg_message_feedback + pkg_session_telemetry_otel --> pkg_session + pkg_session_telemetry_otel --> pkg_session_telemetry pkg_tool_bash --> pkg_agent pkg_tool_bash --> pkg_jobs pkg_tool_bash --> pkg_llm @@ -1323,7 +1325,6 @@ flowchart TD | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) | | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | -| [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-protocol`](../packages/typert/protocol) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | | [`session-projection-cache`](../packages/session/session-projection-cache) | `session` | [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain) | @@ -1366,7 +1367,7 @@ flowchart TD | [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`session`](../packages/core/session) | +| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`permission-presets`](../packages/interaction/permission-presets) | `interaction` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/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), [`shell`](../packages/shell/shell), [`user-approval`](../packages/interaction/user-approval) | | [`jobs-local`](../packages/jobs/jobs-local) | `jobs` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`scope`](../packages/core/scope), [`timeout`](../packages/util/timeout) | | [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) | @@ -1391,6 +1392,7 @@ flowchart TD | [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | `extensions` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | +| [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-protocol`](../packages/typert/protocol) | | [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder) | `guard` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools) | | [`tool-call-timeout-policy`](../packages/guard/timeout-policy) | `guard` | [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-ask-user`](../packages/interaction/tool-ask-user) | `interaction` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | @@ -1400,7 +1402,6 @@ flowchart TD | [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | | [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | -| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | | [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`shell-env`](../packages/shell/shell-env) | `shell` | [`home-paths`](../packages/util/home-paths), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) | @@ -1417,6 +1418,7 @@ flowchart TD | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) | +| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 8049e2d430..d3294070f9 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: a9e1221a564a4ad1af1353278f1215bb33fb9c4f -persistence-catalog.zh.md: 2b74b03b4b783f848385e66e15c40d73f50a788b +persistence-catalog.md: bf70879f80f959c865eca17efbdbce3ef78fda2e +persistence-catalog.zh.md: 1332dfdc8778dde651eeedefab2f87b09d67c06e diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index a9e1221a56..bf70879f80 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -422,7 +422,7 @@ Source: [`packages/fs/tool-present/src/types.ts:15`](../packages/fs/tool-present 'feedback/message-delete': MessageFeedbackDelete ``` -Source: [`packages/feedback/message-feedback/src/types.ts:55`](../packages/feedback/message-feedback/src/types.ts) +Source: [`packages/feedback/message-feedback/src/types.ts:58`](../packages/feedback/message-feedback/src/types.ts) @@ -433,7 +433,7 @@ Source: [`packages/feedback/message-feedback/src/types.ts:55`](../packages/feedb 'feedback/message-put': MessageFeedbackPut ``` -Source: [`packages/feedback/message-feedback/src/types.ts:53`](../packages/feedback/message-feedback/src/types.ts) +Source: [`packages/feedback/message-feedback/src/types.ts:56`](../packages/feedback/message-feedback/src/types.ts) @@ -444,10 +444,10 @@ Source: [`packages/feedback/message-feedback/src/types.ts:53`](../packages/feedb * One recorded human remark about this session. Log-only and independent * of its trigger; it never enters model context or derived history. */ -'feedback/record': { text: string } +'feedback/record': FeedbackRecord ``` -Source: [`packages/feedback/command-feedback/src/index.ts:25`](../packages/feedback/command-feedback/src/index.ts) +Source: [`packages/feedback/command-feedback/src/types.ts:40`](../packages/feedback/command-feedback/src/types.ts) ### `goal/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 2b74b03b4b..1332dfdc87 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -446,10 +446,10 @@ export type SessionEvent = { * One recorded human remark about this session. Log-only and independent * of its trigger; it never enters model context or derived history. */ -'feedback/record': { text: string } +'feedback/record': FeedbackRecord ``` -来源:[`packages/feedback/command-feedback/src/index.ts:25`](../packages/feedback/command-feedback/src/index.ts) +来源:[`packages/feedback/command-feedback/src/types.ts:40`](../packages/feedback/command-feedback/src/types.ts) ### `goal/*` diff --git a/docs/subsystems/feedback.i18n.yaml b/docs/subsystems/feedback.i18n.yaml index db8b17556f..ba31e1ea1d 100644 --- a/docs/subsystems/feedback.i18n.yaml +++ b/docs/subsystems/feedback.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/feedback.md -feedback.md: c538fac50b0c75e43f761a3b58b385840b83e4ef -feedback.zh.md: 4a77f9c0396f3635755c301a80ccaefe398d5ad6 +feedback.md: 2c6728d97a6a62735e4ef8b97983f08060d4a2c5 +feedback.zh.md: db9f03cb82685763bc3b424c0f6b29738f59ec99 diff --git a/docs/subsystems/feedback.md b/docs/subsystems/feedback.md index c538fac50b..2c6728d97a 100644 --- a/docs/subsystems/feedback.md +++ b/docs/subsystems/feedback.md @@ -2,7 +2,7 @@ English | [中文](feedback.zh.md) -[`@deepseek-ai/dsh-message-feedback`](../../packages/feedback/message-feedback) owns editable feedback for individual assistant messages. The canonical Session log stores `feedback/message-put` and `feedback/message-delete`; the immutable Session-level remark remains `feedback/record`. All three are log-only events that never enter model context. +[`@deepseek-ai/dsh-message-feedback`](../../packages/feedback/message-feedback) owns editable feedback for individual assistant messages. The canonical Session log stores `feedback/message-put` and `feedback/message-delete`; the immutable Session-level remark remains `feedback/record`, owned by [`@deepseek-ai/dsh-command-feedback`](../../packages/feedback/command-feedback) together with the `FeedbackCategory` taxonomy both kinds of feedback file under. All three are log-only events that never enter model context. Source: [`packages/feedback/message-feedback/src/types.ts`](../../packages/feedback/message-feedback/src/types.ts) @@ -27,6 +27,8 @@ interface MessageFeedbackItem { readonly rating: MessageFeedbackRating /** Optional explanation, preserved verbatim after validation. */ readonly note?: string + /** Category the human filed a negative judgment under. */ + readonly category?: FeedbackCategory /** Equality-only token replaced by every material create or update. */ readonly version: MessageFeedbackVersion /** Host-assigned creation time in Unix epoch milliseconds. */ @@ -83,6 +85,8 @@ interface MessageFeedbackPutRequest { readonly rating: MessageFeedbackRating /** Optional non-blank explanation. */ readonly note?: string + /** Optional category; absent keeps the item uncategorized. */ + readonly category?: FeedbackCategory /** Observed item version, or `null` to require that no item exists. */ readonly ifVersion: MessageFeedbackVersion | null } @@ -203,11 +207,76 @@ type MessageFeedbackDeleteResult = | MessageFeedbackRejected ``` +## Session feedback types + +Source: [`packages/feedback/command-feedback/src/types.ts`](../../packages/feedback/command-feedback/src/types.ts) + +```ts type-equiv +/** One of the fixed feedback categories; the ids are durable log vocabulary. */ +type FeedbackCategory = + | 'task-result' + | 'instruction-following' + | 'product-interaction' + | 'service-stability' + | 'resource-cost' + | 'security-privacy-permission' + | 'other' +``` + +```ts type-equiv +/** + * One recorded human remark about a Session. Both members are optional: a + * submission with neither still records that the human asked for the + * Session to be reviewed, which is what authorizes log delivery. + */ +interface FeedbackRecord { + /** Free-text remark with surrounding whitespace removed; never empty when present. */ + readonly text?: string + /** Category the human filed the remark under. */ + readonly category?: FeedbackCategory +} +``` + +```ts type-equiv +/** Record one Session-level remark through the Host Remote. */ +interface SessionFeedbackRecordRequest { + /** Live Session the remark describes. */ + readonly sessionId: SessionId + /** Free-text remark; blank text is recorded as absent. */ + readonly text?: string + /** Category the human filed the remark under. */ + readonly category?: FeedbackCategory +} +``` + +```ts type-equiv +/** Stable postcondition of a recorded remark. */ +interface SessionFeedbackRecordValue { + /** The remark is appended to the Session log; flushing follows the Session's own schedule. */ + readonly recorded: true +} +``` + +```ts type-equiv +/** No live Session carries the requested id. */ +interface SessionFeedbackSessionNotFound { + readonly code: 'session-not-found' + readonly sessionId: SessionId +} +``` + +```ts type-equiv +/** Result returned by the `sessionFeedback.record` operation. */ +type SessionFeedbackRecordResult = + | { readonly ok: true; readonly value: SessionFeedbackRecordValue } + | { readonly ok: false; readonly error: SessionFeedbackSessionNotFound } +``` + ## Data and concurrency -Current items are folded from canonical feedback events whose payload `sessionId` matches the owning Session. Each item carries a positive or negative rating, an optional note, Host-assigned `createdAt`/`updatedAt` timestamps, and its own opaque version. Versions are compared only for equality and only against the addressed message; callers do not order or synthesize them. +Current items are folded from canonical feedback events whose payload `sessionId` matches the owning Session. Each item carries a positive or negative rating, an optional note, an optional category, Host-assigned `createdAt`/`updatedAt` timestamps, and its own opaque version. Versions are compared only for equality and only against the addressed message; callers do not order or synthesize them. -`put` uses strict optimistic concurrency: every request for an existing item must match its current `ifVersion`, including a no-op. A conflict returns the authoritative current item (or `null`), so a caller can reconcile a lost response or a concurrent edit without another read. Deleting an already absent item succeeds. A per-Session queue serializes reads and mutations; cold mutations hold a persistence write handle across read, comparison, append, and flush. Matching no-ops append no event. +`put` uses strict optimistic concurrency: every request for an existing item must match its current `ifVersion`, including a no-op (a put repeating the stored rating, note, and category). A conflict returns the authoritative current item (or `null`), so a caller can reconcile a lost response or a concurrent edit without another read. Deleting an already absent item succeeds. A per-Session queue serializes reads and mutations; cold mutations hold a persistence write handle across read, comparison, append, and flush. Matching no-ops append no event. ## Target and lifecycle authority @@ -217,7 +286,7 @@ Fork seeds can contain parent feedback events, but their payload retains the par ## Persistence and Remote contract -Successful message-feedback mutations await canonical persistence: live operations append through the owning Session and require a participating `ctx.sessions.flush` listener; cold operations append and flush through their write handle. Persistence failures propagate rather than reporting success. `maxNoteBytes` is required and bounds note text by UTF-8 bytes; the Web Host composition sets `8192`. The package publishes the Host `messageFeedback.list`, `messageFeedback.put`, and `messageFeedback.delete` unary Remote contract through `TypertRemoteService` and `@Remote`; the generated Cordis API below is the method-level authority. +Successful message-feedback mutations await canonical persistence: live operations append through the owning Session and require a participating `ctx.sessions.flush` listener; cold operations append and flush through their write handle. Persistence failures propagate rather than reporting success. `maxNoteBytes` is required and bounds note text by UTF-8 bytes; the Web Host composition sets `8192`. The package publishes the Host `messageFeedback.list`, `messageFeedback.put`, and `messageFeedback.delete` unary Remote contract through `TypertRemoteService` and `@Remote`; `command-feedback` publishes `sessionFeedback.record` the same way for Session-level remarks on live Sessions. The generated Cordis API below is the method-level authority. Plugin disposal closes operation admission and drains accepted per-Session queue work. @@ -225,12 +294,14 @@ When explicitly enabled, [`session-log-deepseek`](../../packages/session/session ## Web surface -[`@deepseek-ai/dsh-client-ui-message-feedback`](../../packages/client/ui-message-feedback) is the browser consumer. `@deepseek-ai/dsh-api-remotes` mounts the generated `messageFeedback` contribution, so the plugin calls `ctx.remote.messageFeedback` and never touches the transport. +[`@deepseek-ai/dsh-client-ui-message-feedback`](../../packages/client/ui-message-feedback) is the browser consumer. `@deepseek-ai/dsh-api-remotes` mounts the generated `messageFeedback` and `sessionFeedback` contributions, so the plugin calls `ctx.remote.messageFeedback` and `ctx.remote.sessionFeedback` and never touches the transport. The controls are the `feedback` entry (order 10) of the `conversation.chat.assistant-actions` list slot, which `ui-conversation` declares and renders inside the finalized assistant message's IconActions row. `AssistantMessageNode` carries the optional `messageId` from the `assistant/message` event. The field is absent on interruption-frozen partials, and the render site skips the slot when it is absent. The strip renders once per turn, on the closing assistant message: the Host accepts every append-origin step message as a target, but earlier steps of a multi-step turn render tool rows rather than a rateable body, so the UI exposes a narrower set than the Host contract allows. One `MessageFeedbackController` per Session backs every message control in that Session: a single `list` read seeds the whole transcript, deferred to first hover or focus rather than fired on mount. Each mutation sends the version that controller last observed as `ifVersion`; a `version-conflict` reply carries the authoritative item, so the controller reconciles from the reply instead of refetching. Mutations serialize per Session so a queued operation compares against the committed version. A `connection/reset` refreshes only Sessions already read. +Like records the bare positive judgment at once and shows the acknowledgement toast. Dislike opens the Session's feedback dialog, the `feedback-dialog` entry of `conversation.input.overlay`: the shared Modal card with seven category chips and a detail box. Submit puts a negative judgment carrying the chosen category and the trimmed description, or neither. The same dialog opens for the Session from a bare `/feedback` — a decoration `ui-commands` routes as an `action` — and then records through `sessionFeedback.record`; `/feedback ` keeps the Host command path. Clicking a recorded rating retracts it. + ## Boundaries and limitations - The operation queue is process-local; cold writer exclusion relies on the selected persistence provider. @@ -240,7 +311,8 @@ One `MessageFeedbackController` per Session backs every message control in that - The Host contract records no authenticated actor or audit identity and therefore assumes a trusted caller boundary. - The Web controls appear in the chat view only. The trajectory and waterfall views render no feedback entry even though their assistant nodes carry the same `messageId`. - The Web controller does not consume feedback log events, so a second tab's rating becomes visible on reconnect or on the next conflict reply rather than immediately. -- The note editor does not pre-check `maxNoteBytes`; an oversized note fails on save with `note-too-large` rather than while typing. +- The dialog does not pre-check `maxNoteBytes`; an oversized description for a message fails on submit with `note-too-large` rather than while typing. A Session remark has no size bound, as the `/feedback` command never had one. +- `sessionFeedback.record` serves live Sessions only and answers `session-not-found` otherwise; the dialog reports that failure when its Session retires while it is open. @@ -282,6 +354,24 @@ Session-log service; cold operations never construct a Session or Agent. Source: [`packages/feedback/message-feedback/src/index.ts`](../../packages/feedback/message-feedback/src/index.ts) + + +### `ctx.sessionFeedback` — `SessionFeedbackService` + +Host Remote through which a product surface records a Session-level remark. + +```ts cordis-catalog +/** + * Record one remark on a live Session. + * @param request - target Session plus the optional text and category. + * @returns the recorded postcondition, or `session-not-found` when no live + * Session carries the id. + */ +@Remote('record') record(request: SessionFeedbackRecordRequest): Promise +``` + +Source: [`packages/feedback/command-feedback/src/index.ts`](../../packages/feedback/command-feedback/src/index.ts) + ### `feedback/*` events diff --git a/docs/subsystems/feedback.zh.md b/docs/subsystems/feedback.zh.md index 4a77f9c039..db9f03cb82 100644 --- a/docs/subsystems/feedback.zh.md +++ b/docs/subsystems/feedback.zh.md @@ -2,7 +2,7 @@ [English](feedback.md) | 中文 -[`@deepseek-ai/dsh-message-feedback`](../../packages/feedback/message-feedback)拥有针对单条 assistant 消息的可编辑反馈。权威 Session 日志保存 `feedback/message-put` 和 `feedback/message-delete`;不可变的 Session 级备注仍使用 `feedback/record`。三者都是仅写日志的事件,绝不进入模型上下文。 +[`@deepseek-ai/dsh-message-feedback`](../../packages/feedback/message-feedback)拥有针对单条 assistant 消息的可编辑反馈。权威 Session 日志保存 `feedback/message-put` 和 `feedback/message-delete`;不可变的 Session 级备注仍使用 `feedback/record`,由 [`@deepseek-ai/dsh-command-feedback`](../../packages/feedback/command-feedback) 连同两种反馈共用的 `FeedbackCategory` 分类表一起拥有。三者都是仅写日志的事件,绝不进入模型上下文。 来源:[`packages/feedback/message-feedback/src/types.ts`](../../packages/feedback/message-feedback/src/types.ts) @@ -27,6 +27,8 @@ interface MessageFeedbackItem { readonly rating: MessageFeedbackRating /** Optional explanation, preserved verbatim after validation. */ readonly note?: string + /** Category the human filed a negative judgment under. */ + readonly category?: FeedbackCategory /** Equality-only token replaced by every material create or update. */ readonly version: MessageFeedbackVersion /** Host-assigned creation time in Unix epoch milliseconds. */ @@ -83,6 +85,8 @@ interface MessageFeedbackPutRequest { readonly rating: MessageFeedbackRating /** Optional non-blank explanation. */ readonly note?: string + /** Optional category; absent keeps the item uncategorized. */ + readonly category?: FeedbackCategory /** Observed item version, or `null` to require that no item exists. */ readonly ifVersion: MessageFeedbackVersion | null } @@ -203,11 +207,76 @@ type MessageFeedbackDeleteResult = | MessageFeedbackRejected ``` +## Session 反馈类型 + +来源:[`packages/feedback/command-feedback/src/types.ts`](../../packages/feedback/command-feedback/src/types.ts) + +```ts type-equiv +/** One of the fixed feedback categories; the ids are durable log vocabulary. */ +type FeedbackCategory = + | 'task-result' + | 'instruction-following' + | 'product-interaction' + | 'service-stability' + | 'resource-cost' + | 'security-privacy-permission' + | 'other' +``` + +```ts type-equiv +/** + * One recorded human remark about a Session. Both members are optional: a + * submission with neither still records that the human asked for the + * Session to be reviewed, which is what authorizes log delivery. + */ +interface FeedbackRecord { + /** Free-text remark with surrounding whitespace removed; never empty when present. */ + readonly text?: string + /** Category the human filed the remark under. */ + readonly category?: FeedbackCategory +} +``` + +```ts type-equiv +/** Record one Session-level remark through the Host Remote. */ +interface SessionFeedbackRecordRequest { + /** Live Session the remark describes. */ + readonly sessionId: SessionId + /** Free-text remark; blank text is recorded as absent. */ + readonly text?: string + /** Category the human filed the remark under. */ + readonly category?: FeedbackCategory +} +``` + +```ts type-equiv +/** Stable postcondition of a recorded remark. */ +interface SessionFeedbackRecordValue { + /** The remark is appended to the Session log; flushing follows the Session's own schedule. */ + readonly recorded: true +} +``` + +```ts type-equiv +/** No live Session carries the requested id. */ +interface SessionFeedbackSessionNotFound { + readonly code: 'session-not-found' + readonly sessionId: SessionId +} +``` + +```ts type-equiv +/** Result returned by the `sessionFeedback.record` operation. */ +type SessionFeedbackRecordResult = + | { readonly ok: true; readonly value: SessionFeedbackRecordValue } + | { readonly ok: false; readonly error: SessionFeedbackSessionNotFound } +``` + ## 数据与并发 -当前条目由 payload 中 `sessionId` 与所属 Session 匹配的权威反馈事件归约得到。每个条目携带好评或差评、可选备注、Host 分配的 `createdAt`/`updatedAt` 时间戳及自己的 opaque version。version 只能用于相等比较,且只与目标消息比较;调用方不能排序或自行合成它。 +当前条目由 payload 中 `sessionId` 与所属 Session 匹配的权威反馈事件归约得到。每个条目携带好评或差评、可选备注、可选分类、Host 分配的 `createdAt`/`updatedAt` 时间戳及自己的 opaque version。version 只能用于相等比较,且只与目标消息比较;调用方不能排序或自行合成它。 -`put` 采用严格乐观并发:已有条目的每次请求都必须匹配当前 `ifVersion`,即使请求不会改变目标值。冲突会返回权威当前条目(不存在时为 `null`),因此调用方无需额外读取,即可协调丢失响应或并发编辑。删除已经不存在的条目同样成功。按 Session 划分的队列串行执行读取与变更;cold 变更在读取、比较、追加和 flush 期间持有持久化写句柄。匹配版本的无变更操作不追加事件。 +`put` 采用严格乐观并发:已有条目的每次请求都必须匹配当前 `ifVersion`,即使请求不会改变目标值(重复已存评分、备注与分类的 put)。冲突会返回权威当前条目(不存在时为 `null`),因此调用方无需额外读取,即可协调丢失响应或并发编辑。删除已经不存在的条目同样成功。按 Session 划分的队列串行执行读取与变更;cold 变更在读取、比较、追加和 flush 期间持有持久化写句柄。匹配版本的无变更操作不追加事件。 ## 目标与生命周期权威 @@ -217,7 +286,7 @@ fork 种子可以包含父 Session 的反馈事件,但 payload 保留父级 `s ## 持久化与 Remote 约定 -成功的消息反馈变更会等待权威持久化完成:live 操作通过所属 Session 追加,并要求有 `ctx.sessions.flush` 监听器参与;cold 操作通过写句柄追加并 flush。持久化故障会原样传播,不会报告成功。`maxNoteBytes` 为必填项,按 UTF-8 字节限制备注文本;Web Host 组合将其设为 `8192`。该包通过 `TypertRemoteService` 与 `@Remote` 发布 Host `messageFeedback.list`、`messageFeedback.put` 和 `messageFeedback.delete` 一元 Remote 约定;下方生成的 Cordis API 是方法级权威。 +成功的消息反馈变更会等待权威持久化完成:live 操作通过所属 Session 追加,并要求有 `ctx.sessions.flush` 监听器参与;cold 操作通过写句柄追加并 flush。持久化故障会原样传播,不会报告成功。`maxNoteBytes` 为必填项,按 UTF-8 字节限制备注文本;Web Host 组合将其设为 `8192`。该包通过 `TypertRemoteService` 与 `@Remote` 发布 Host `messageFeedback.list`、`messageFeedback.put` 和 `messageFeedback.delete` 一元 Remote 约定;`command-feedback` 以同样方式发布面向 live Session 的 Session 级备注 `sessionFeedback.record`。下方生成的 Cordis API 是方法级权威。 插件释放会关闭操作接纳,并排空已进入各 Session 队列的工作。 @@ -225,12 +294,14 @@ fork 种子可以包含父 Session 的反馈事件,但 payload 保留父级 `s ## Web 界面 -[`@deepseek-ai/dsh-client-ui-message-feedback`](../../packages/client/ui-message-feedback) 是浏览器侧消费方。`@deepseek-ai/dsh-api-remotes` 挂载生成的 `messageFeedback` 贡献,因此该插件调用 `ctx.remote.messageFeedback`,不接触传输层。 +[`@deepseek-ai/dsh-client-ui-message-feedback`](../../packages/client/ui-message-feedback) 是浏览器侧消费方。`@deepseek-ai/dsh-api-remotes` 挂载生成的 `messageFeedback` 与 `sessionFeedback` 贡献,因此该插件调用 `ctx.remote.messageFeedback` 与 `ctx.remote.sessionFeedback`,不接触传输层。 控件是 `conversation.chat.assistant-actions` list slot 的 `feedback` 条目(order 10),该 slot 由 `ui-conversation` 声明,并渲染在已定稿助手消息的 IconActions 行内。`AssistantMessageNode` 携带来自 `assistant/message` 事件的可选 `messageId`。被中断冻结的部分输出没有该字段,渲染点在字段缺失时跳过该 slot。该操作栏每个 Turn 渲染一次,位于收尾的助手消息上:Host 接受每条 append-origin 步骤消息作为目标,但多步骤 Turn 中较早的步骤渲染的是工具行而非可评分正文,因此 UI 暴露的范围比 Host 约定允许的更窄。 每个 Session 一个 `MessageFeedbackController`,支撑该 Session 内所有消息的控件:一次 `list` 读取即填充整段对话,且延迟到首次 hover 或 focus 才发起,而非挂载时触发。每次变更把该 controller 最后观察到的版本作为 `ifVersion` 发送;`version-conflict` 响应携带权威条目,controller 据此对账而不重新拉取。变更按 Session 串行,排队操作与已提交版本比较。`connection/reset` 只刷新已读取过的 Session。 +点赞立即记录不带备注的好评并显示确认 toast。点踩打开该 Session 的反馈弹窗,即 `conversation.input.overlay` 的 `feedback-dialog` 条目:共用的 Modal 卡片,里面是七个分类标签和一个详情框。提交会 put 一条差评,带上所选分类与去除首尾空白的描述,两者也可都不带。不带文本的 `/feedback`(`ui-commands` 以 `action` 路由的一个装饰)为 Session 打开同一个弹窗,随后通过 `sessionFeedback.record` 记录;`/feedback ` 仍走宿主命令路径。再次点击已记录的评分会撤回它。 + ## 边界与限制 - 操作队列仅在进程内生效;cold 写入排他性依赖所选持久化提供方。 @@ -240,7 +311,8 @@ fork 种子可以包含父 Session 的反馈事件,但 payload 保留父级 `s - Host 约定不记录已认证的 actor 或审计身份,因此假设调用方边界可信。 - Web 控件只出现在对话视图。trajectory 与 waterfall 视图不渲染反馈条目,尽管它们的助手节点携带相同的 `messageId`。 - Web 控制器不消费反馈日志事件,因此另一个标签页的评分要等到重连或下一次冲突响应才可见,不会立即出现。 -- 备注编辑器不预先校验 `maxNoteBytes`;超长备注在保存时以 `note-too-large` 失败,而不是在输入过程中。 +- 弹窗不预先校验 `maxNoteBytes`;针对消息的超长描述在提交时以 `note-too-large` 失败,而不是在输入过程中。Session 级备注没有大小上限,`/feedback` 命令从来也没有。 +- `sessionFeedback.record` 只服务 live Session,否则回答 `session-not-found`;弹窗打开期间 Session 退役时,弹窗会报告该失败。 @@ -282,6 +354,24 @@ Session-log service; cold operations never construct a Session or Agent. Source: [`packages/feedback/message-feedback/src/index.ts`](../../packages/feedback/message-feedback/src/index.ts) + + +### `ctx.sessionFeedback` — `SessionFeedbackService` + +Host Remote through which a product surface records a Session-level remark. + +```ts cordis-catalog +/** + * Record one remark on a live Session. + * @param request - target Session plus the optional text and category. + * @returns the recorded postcondition, or `session-not-found` when no live + * Session carries the id. + */ +@Remote('record') record(request: SessionFeedbackRecordRequest): Promise +``` + +Source: [`packages/feedback/command-feedback/src/index.ts`](../../packages/feedback/command-feedback/src/index.ts) + ### `feedback/*` events diff --git a/docs/subsystems/persistence.i18n.yaml b/docs/subsystems/persistence.i18n.yaml index c5c852b3a2..e5a3fe2a7e 100644 --- a/docs/subsystems/persistence.i18n.yaml +++ b/docs/subsystems/persistence.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/persistence.md -persistence.md: 8ba4e74768050646df4904d4ae1a978781685f35 -persistence.zh.md: 1821b07df7686a7aa77cfb5c837f903e199ccf94 +persistence.md: b75ed288e1d034dc361457c8124a9974ecadad92 +persistence.zh.md: 1e5fa4269c520f2b196b245e4f2af4e8601da8eb diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md index 8ba4e74768..b75ed288e1 100644 --- a/docs/subsystems/persistence.md +++ b/docs/subsystems/persistence.md @@ -186,7 +186,7 @@ interface SessionHeader { ## Format refusal — logs a build cannot faithfully read -A backend refuses a log it cannot faithfully interpret with `SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged. `stat` and `list` classify the highest canonical generation and translate a supported historical header without reading or mutating its body. Historical `open` calls share one per-session migration preparation before returning current logical values and leave every source path, byte, and inode unchanged. The JSONL provider returns a read handle from that in-memory result without publishing; a write open holds its single-writer claim and file lease while it reuses the preparation, exclusively publishes the final current generation, and only then returns the writable handle. A future highest generation refuses even when an older readable generation remains. Current v2 restoration retains installed extensions and unknown events carrying `ignorable: true`; historical v0/v1 migration refuses an unknown type even when marked ignorable. The message appends the selected raw log path when the backend keeps one artifact per session. An out-of-tree backend must enforce equivalent current-only handle values and direction-aware refusals at its physical-format entry. The [released-format migration decision](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md) owns the chain and immutable-publication rules. +A backend refuses a log it cannot faithfully interpret with `SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged. `stat` and `list` classify the highest canonical generation and translate a supported historical header without reading or mutating its body. Historical `open` calls share one per-session migration preparation before returning current logical values and leave every source path, byte, and inode unchanged. The JSONL provider returns a read handle from that in-memory result without publishing; a write open holds its single-writer claim and file lease while it reuses the preparation, exclusively publishes the final current generation, and only then returns the writable handle. A future highest generation refuses even when an older readable generation remains. Current-format restoration retains installed extensions and unknown events carrying `ignorable: true`; historical v0/v1/v2 migration refuses an unknown type even when marked ignorable. The message appends the selected raw log path when the backend keeps one artifact per session. An out-of-tree backend must enforce equivalent current-only handle values and direction-aware refusals at its physical-format entry. The [released-format migration decision](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md) owns the chain and immutable-publication rules. ## `CreateSessionOptions` — seeding and metadata diff --git a/docs/subsystems/persistence.zh.md b/docs/subsystems/persistence.zh.md index 1821b07df7..1e5fa4269c 100644 --- a/docs/subsystems/persistence.zh.md +++ b/docs/subsystems/persistence.zh.md @@ -186,7 +186,7 @@ interface SessionHeader { ## 格式拒绝:本构建无法可靠读取的日志 -后端用 `SessionFormatUnsupportedError` 拒绝无法可靠解读的日志,它与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏。`stat` 与 `list` 会对最高规范 generation 分类,并在不读取或改变正文的前提下转换受支持的历史 header。历史 `open` 会共享每个 Session 唯一的一次 migration preparation,再返回当前逻辑值,并保持每个源路径、字节与 inode 不变。JSONL provider 直接从该内存结果返回读句柄而不发布;写 open 则在持有单写者 claim 与文件 lease 时复用 preparation、排他发布最终 current generation,随后才返回可写句柄。即使仍有较旧的可读 generation,最高的未来 generation 仍会导致拒绝。当前 v2 恢复会保留已安装扩展和带 `ignorable: true` 的未知事件;历史 v0/v1 迁移则会拒绝未知类型,即使它带有 ignorable 标记。后端为每个会话保留独立文件时,消息附上选定的原始日志路径。仓库外后端必须在自己的物理格式入口提供等价的仅当前句柄值与方向感知拒绝。[已发布格式迁移决策](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md)负责迁移链与不可变发布规则。 +后端用 `SessionFormatUnsupportedError` 拒绝无法可靠解读的日志,它与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏。`stat` 与 `list` 会对最高规范 generation 分类,并在不读取或改变正文的前提下转换受支持的历史 header。历史 `open` 会共享每个 Session 唯一的一次 migration preparation,再返回当前逻辑值,并保持每个源路径、字节与 inode 不变。JSONL provider 直接从该内存结果返回读句柄而不发布;写 open 则在持有单写者 claim 与文件 lease 时复用 preparation、排他发布最终 current generation,随后才返回可写句柄。即使仍有较旧的可读 generation,最高的未来 generation 仍会导致拒绝。当前格式恢复会保留已安装扩展和带 `ignorable: true` 的未知事件;历史 v0/v1/v2 迁移则会拒绝未知类型,即使它带有 ignorable 标记。后端为每个会话保留独立文件时,消息附上选定的原始日志路径。仓库外后端必须在自己的物理格式入口提供等价的仅当前句柄值与方向感知拒绝。[已发布格式迁移决策](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md)负责迁移链与不可变发布规则。 ## `CreateSessionOptions`:seed 与元数据 diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 26fee2e323..883c778ccb 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session.md -session.md: de0930ea7effcba69bc1f9a4dd405ceda23919d7 -session.zh.md: ec15dbdec3a8887d8fa87c9698b8ad14699a534b +session.md: 50dbbf6474f1b1e756dedd508bb1d8b0ef349832 +session.zh.md: 9a1b1399e701a1fa2bda2ad59b59c9fbe484ef4a diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index de0930ea7e..50dbbf6474 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -672,7 +672,7 @@ The hook bridges' `hook/invoked` / `hook/result` pairs (from `@deepseek-ai/dsh-h ## Durability contract -What a persistence backend relies on: the durable log persists every event losslessly, and every Assistant attempt is one `assistant/message` or `assistant/attempt` whose embedded compact stream preserves the original timed chunks. `seq` stays contiguous across these settlements and all interleaved events. A backend may choose its own storage framing for an event batch as long as a handle's `read()` returns the exact appended events; current JSONL v2 writes one row per event (see [persistence.md](persistence.md)). All `event.data` must be JSON-serializable; `Session.append` enforces this at the source (throwing on non-serializable data), so a bad event never enters the log and `session.snapshotEvents()` always equals what a backend can persist. Adding an event type that carries non-serializable data, corrupts core execution nesting, or violates its owner's declared relation is a breaking change to the on-disk format. +What a persistence backend relies on: the durable log persists every event losslessly, and every Assistant attempt is one `assistant/message` or `assistant/attempt` whose embedded compact stream preserves the original timed chunks. `seq` stays contiguous across these settlements and all interleaved events. A backend may choose its own storage framing for an event batch as long as a handle's `read()` returns the exact appended events; current JSONL writes one row per event (see [persistence.md](persistence.md)). All `event.data` must be JSON-serializable; `Session.append` enforces this at the source (throwing on non-serializable data), so a bad event never enters the log and `session.snapshotEvents()` always equals what a backend can persist. Adding an event type that carries non-serializable data, corrupts core execution nesting, or violates its owner's declared relation is a breaking change to the on-disk format. The backends that consume this contract are on [persistence.md](persistence.md). diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index ec15dbdec3..9a1b1399e7 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -676,7 +676,7 @@ interface TurnEndReasonMap { ## 持久性约定 -持久化后端依赖的约定如下:持久日志无损保存每个事件,每个 Assistant attempt 都是一个 `assistant/message` 或 `assistant/attempt`,其嵌入式紧凑 stream 会保留原始带时间 chunk。`seq` 在这些 settlement 与所有交错事件之间保持连续。后端可以为事件批次选择自己的存储 framing,只要句柄的 `read()` 返回与追加时完全一致的事件即可;当前 JSONL v2 每个事件写一行(见 [persistence.md](persistence.zh.md))。所有 `event.data` 都必须可序列化为 JSON;`Session.append` 会从源头强制这一要求(遇到不可序列化数据时抛出),因此错误事件绝不会进入日志,`session.snapshotEvents()` 始终与后端可持久化的内容一致。新增会携带不可序列化数据、破坏核心执行嵌套或违反事件所有方声明关系的事件类型,都会构成磁盘格式的破坏性变更。 +持久化后端依赖的约定如下:持久日志无损保存每个事件,每个 Assistant attempt 都是一个 `assistant/message` 或 `assistant/attempt`,其嵌入式紧凑 stream 会保留原始带时间 chunk。`seq` 在这些 settlement 与所有交错事件之间保持连续。后端可以为事件批次选择自己的存储 framing,只要句柄的 `read()` 返回与追加时完全一致的事件即可;当前 JSONL 每个事件写一行(见 [persistence.md](persistence.zh.md))。所有 `event.data` 都必须可序列化为 JSON;`Session.append` 会从源头强制这一要求(遇到不可序列化数据时抛出),因此错误事件绝不会进入日志,`session.snapshotEvents()` 始终与后端可持久化的内容一致。新增会携带不可序列化数据、破坏核心执行嵌套或违反事件所有方声明关系的事件类型,都会构成磁盘格式的破坏性变更。 消费此约定的后端见 [persistence.md](persistence.zh.md)。 diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 49f6610bd2..e5150a443c 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/tool-catalog.md -tool-catalog.md: c2ea95ff460b65fbba789738b16b0c67a7c91948 -tool-catalog.zh.md: 41fc1eaa3c6a0acf17bde4b69c97413b64872930 +tool-catalog.md: 928f7537ab212edf6af1dec93077caed2bb003b7 +tool-catalog.zh.md: 5ac34f6188f2b0689e02b72595375f00b02d5f6f diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index c2ea95ff46..928f7537ab 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -225,7 +225,7 @@ The bash tool is the model-facing consumer of the bash executor seam. A `run_in_ ### `present` -Declare existing workspace files as final deliverables. The user opens the current source files; their contents are not copied or preserved. Create the files before calling this tool. +Declare existing workspace files as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved. ```json { diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 41fc1eaa3c..5ac34f6188 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -229,7 +229,7 @@ bash 工具是 bash 执行器 seam 面向模型的消费方。使用 `run_in_bac ### `present` -声明交付已有的工作区文件。用户打开当前源文件;不复制或保存其内容。调用工具前先创建文件。 +声明交付已有的工作区文件。如果你创建或更新的文件是用户要求接收的成果,则必须在写入完成后、最终回复前调用 present,包括通过 Bash 或代码执行创建的文件。在回复中提到文件路径不能替代这次调用。文件必须已存在。用户打开当前源文件;不复制或保存其内容。 ```json { diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 3d91c68dc6..0d7280d631 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -84,6 +84,7 @@ "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-api-workspace-files": "workspace:^", - "zod": "^4.4.3" + "zod": "^4.4.3", + "@deepseek-ai/dsh-command-feedback": "workspace:^" } } diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index bc93c25cca..fa0dae245e 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -9,6 +9,7 @@ import llmRemote from '@deepseek-ai/dsh-llm/remote' import dynamicRemote from '@deepseek-ai/dsh-cordis-host-runner/remote' import pluginInventoryRemote from '@deepseek-ai/dsh-host-plugin-inventory/remote' import messageFeedbackRemote from '@deepseek-ai/dsh-message-feedback/remote' +import sessionFeedbackRemote from '@deepseek-ai/dsh-command-feedback/remote' import fileUploadsRemote from '@deepseek-ai/dsh-client-file-upload/remote' import sessionReferencesRemote from '@deepseek-ai/dsh-session-reference/remote' import subagentsRemote from '@deepseek-ai/dsh-subagent/remote' @@ -26,6 +27,7 @@ export type {} from '@deepseek-ai/dsh-goal/remote' export type {} from '@deepseek-ai/dsh-llm/remote' export type {} from '@deepseek-ai/dsh-host-plugin-inventory/remote' export type {} from '@deepseek-ai/dsh-message-feedback/remote' +export type {} from '@deepseek-ai/dsh-command-feedback/remote' export type {} from '@deepseek-ai/dsh-client-file-upload/remote' export type {} from '@deepseek-ai/dsh-session-reference/remote' export type {} from '@deepseek-ai/dsh-subagent/remote' @@ -150,7 +152,7 @@ export async function apply(ctx: Context): Promise<() => Promise> { try { for (const contribution of [ agentPresetsRemote, commandsRemote, settingsControllerRemote, goalsRemote, llmRemote, dynamicRemote, - pluginInventoryRemote, messageFeedbackRemote, fileUploadsRemote, sessionReferencesRemote, + pluginInventoryRemote, messageFeedbackRemote, sessionFeedbackRemote, fileUploadsRemote, sessionReferencesRemote, subagentsRemote, sessionRemote, workspaceRemote, workspaceFilesRemote, ]) { disposers.push(await ctx.remote.$mount(contribution)) diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index 589d5e92bd..7222a73c98 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -79,6 +79,9 @@ }, { "path": "../../typert/protocol" + }, + { + "path": "../../feedback/command-feedback" } ] } diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 00e4c8b67f..cf4603031f 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -315,8 +315,9 @@ - id: ui-goal name: '@deepseek-ai/dsh-client-ui-goal' - # Per-message feedback: Like/Dislike plus an optional note in the - # assistant-message action strip, over the messageFeedback Remote. + # The feedback surface: Like/Dislike in the assistant-message action + # strip, the feedback dialog behind Dislike and /feedback with its + # acknowledgement toast, over the messageFeedback and sessionFeedback Remotes. - id: ui-message-feedback name: '@deepseek-ai/dsh-client-ui-message-feedback' diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index c23797ba1f..2a62fbbd35 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/README.md -README.md: 6bb433b8411fa9db3d6de981a24895e3c7b674c4 -README.zh.md: c04292b86becba404e5dbb58ca924833ee88f58b +README.md: cf4b6aceb320d942d695fb1dce53c2e67e53a969 +README.zh.md: 1f895de009e82688ede9b3a83807bae9689ccb80 diff --git a/packages/client/README.md b/packages/client/README.md index 6bb433b841..cf4b6aceb3 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -71,7 +71,7 @@ The kernel packages boot and serve the page; the UI feature packages present it. | [`ui-settings-models/`](ui-settings-models/README.md) | Provides model-provider configuration and DeepSeek onboarding | — | | [`ui-settings-plugin-inventory/`](ui-settings-plugin-inventory/README.md) | Contributes the read-only Host Loader inventory tab to Plugins settings | — | | [`ui-deliverables/`](ui-deliverables/README.md) | Produces the produced-files turn tail and clickable final-response file references | — | -| [`ui-message-feedback/`](ui-message-feedback/README.md) | Contributes per-message feedback controls to the assistant-message action strip | — | +| [`ui-message-feedback/`](ui-message-feedback/README.md) | The feedback surface: per-message Like/Dislike in the assistant-message action strip, and the feedback dialog behind Dislike and `/feedback` | — | | [`ui-directory-picker-browse/`](ui-directory-picker-browse/README.md) | In-app directory browsing surface for the workspace directory flow | — | | [`ui-directory-picker-native/`](ui-directory-picker-native/README.md) | Native directory-picker surface driving the host's OS chooser | — | | [`ui-open-in-app/`](ui-open-in-app/README.md) | Session-header split button opening the workspace directory in an installed application | — | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index c04292b86b..1f895de009 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -71,7 +71,7 @@ kind: "package-group" | [`ui-settings-models/`](ui-settings-models/README.zh.md) | 提供模型提供方配置与 DeepSeek 引导 | — | | [`ui-settings-plugin-inventory/`](ui-settings-plugin-inventory/README.zh.md) | 向“插件”设置贡献只读的 Host Loader 清单标签页 | — | | [`ui-deliverables/`](ui-deliverables/README.zh.md) | 生成已产出文件的轮次尾部与可点击的最终响应文件引用 | — | -| [`ui-message-feedback/`](ui-message-feedback/README.zh.md) | 向助手消息操作条贡献逐消息反馈控件 | — | +| [`ui-message-feedback/`](ui-message-feedback/README.zh.md) | 反馈界面:助手消息操作条中的逐消息赞踩,以及点踩与 `/feedback` 背后的反馈弹窗 | — | | [`ui-directory-picker-browse/`](ui-directory-picker-browse/README.zh.md) | 面向工作区目录流程的应用内目录浏览界面 | — | | [`ui-directory-picker-native/`](ui-directory-picker-native/README.zh.md) | 驱动宿主 OS 选择器的原生目录选择界面 | — | | [`ui-open-in-app/`](ui-open-in-app/README.zh.md) | 在已安装应用中打开 workspace 目录的会话头部分体按钮 | — | diff --git a/packages/client/ui-commands/README.i18n.yaml b/packages/client/ui-commands/README.i18n.yaml index dc82321424..4ebf705b73 100644 --- a/packages/client/ui-commands/README.i18n.yaml +++ b/packages/client/ui-commands/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-commands/README.md -README.md: e1264d10bd8ff807d8f348339e0c055607579407 -README.zh.md: c5323149aea1e38ff09262db9bd2aacad7b284ea +README.md: c5f3c55c08a6df8e6c3f122d0c5e341721c2b41a +README.zh.md: f728c03e067f74ba5142464adc17531225034aad diff --git a/packages/client/ui-commands/README.md b/packages/client/ui-commands/README.md index e1264d10bd..c5f3c55c08 100644 --- a/packages/client/ui-commands/README.md +++ b/packages/client/ui-commands/README.md @@ -1,5 +1,5 @@ --- -description: "Client command API for the Web GUI: the / command source, three dispatch kinds, the per-session command directory, and popupSelect registration for business packages; for users and maintainers of slash commands." +description: "Client command API for the Web GUI: the / command source, three dispatch kinds, the per-session command directory, and popupSelect and action registration for business packages; for users and maintainers of slash commands." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Typing a `/` command in the composer opens the matching surface — a registered popup, a host command's input, or a direct execution — and a command line is never silently downgraded to a plain prompt. Business packages contribute command surfaces through `ctx.commandUi`, registering a popupSelect spec (`/model`, `/permission`) or decorating an existing host command with a picker while the host keeps its catalog row and argument claim. Space and Enter resolve the line against the session's directory: a host descriptor with `input` is `leadingInput`, a registered `CommandUiSpec` is `popupSelect`, and everything else is `execute`. +Typing a `/` command in the composer opens the matching surface — a registered popup, a host command's input, or a direct execution — and a command line is never silently downgraded to a plain prompt. Business packages contribute command surfaces through `ctx.commandUi`: a popupSelect spec (`/model`, `/permission`) or an action (`/feedback`), registered as a command or decorating an existing host command while the host keeps its catalog row and argument claim. Space and Enter resolve the line against the session's directory: a host descriptor with `input` is `leadingInput`, a registered `CommandUiSpec` is its kind, and everything else is `execute`. ## Table of Contents diff --git a/packages/client/ui-commands/README.zh.md b/packages/client/ui-commands/README.zh.md index c5323149ae..f728c03e06 100644 --- a/packages/client/ui-commands/README.zh.md +++ b/packages/client/ui-commands/README.zh.md @@ -1,5 +1,5 @@ --- -description: "Web GUI 的客户端命令 API:/ 命令 source、三类派发、会话级命令目录,以及面向业务包的 popupSelect 注册;供斜杠命令的用户与维护者阅读。" +description: "Web GUI 的客户端命令 API:/ 命令 source、三类派发、会话级命令目录,以及面向业务包的 popupSelect 与 action 注册;供斜杠命令的用户与维护者阅读。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -在 composer 中键入 `/` 命令会打开匹配的表面——已注册的弹窗、宿主命令的输入或直接执行——命令行绝不会被静默降级为普通提示词。业务包经 `ctx.commandUi` 贡献命令表面:注册 popupSelect 贡献项(`/model`、`/permission`),或用选择器装饰既有宿主命令,宿主保留其目录行与参数声明。空格与回车对照会话目录解析命令行:带 `input` 的宿主描述符是 `leadingInput`,注册了 `CommandUiSpec` 的是 `popupSelect`,其余全部是 `execute`。 +在 composer 中键入 `/` 命令会打开匹配的表面——已注册的弹窗、宿主命令的输入或直接执行——命令行绝不会被静默降级为普通提示词。业务包经 `ctx.commandUi` 贡献命令表面:popupSelect 贡献项(`/model`、`/permission`)或 action(`/feedback`),既可注册为命令,也可装饰既有宿主命令,宿主保留其目录行与参数声明。空格与回车对照会话目录解析命令行:带 `input` 的宿主描述符是 `leadingInput`,注册了 `CommandUiSpec` 的按其种类派发,其余全部是 `execute`。 ## 目录 diff --git a/packages/client/ui-commands/src/client/contract.ts b/packages/client/ui-commands/src/client/contract.ts index fe479990f4..326f933d02 100644 --- a/packages/client/ui-commands/src/client/contract.ts +++ b/packages/client/ui-commands/src/client/contract.ts @@ -31,12 +31,30 @@ export interface SelectOption { * The shell component is owned by ui-commands; business never sees it. Both * callbacks receive the ClientSessionContext captured at popup open. */ -export type CommandUiSpec = { +export interface PopupSelectSpec { readonly kind: 'popupSelect' options(session: ClientSessionContext, signal: AbortSignal): Promise onSelect(option: SelectOption, session: ClientSessionContext): void | Promise } +/** + * Business registration for the action command kind: a bare invocation + * consumes the trigger token and runs one client-side callback (the Feedback + * row opens the feedback dialog). It submits nothing, so an + * attachment-carrying draft never refuses it. + */ +export interface ActionSpec { + readonly kind: 'action' + /** + * Run the action for one session. + * @param session - the ClientSessionContext captured at invocation. + */ + run(session: ClientSessionContext): void +} + +/** The UI behavior of a contribution or decoration. */ +export type CommandUiSpec = PopupSelectSpec | ActionSpec + /** * One client-owned command contribution: a slash-menu entry whose behavior * lives entirely on the client (no host descriptor). Merged with the host @@ -50,7 +68,7 @@ export interface CommandContribution { readonly description: () => string /** Capability filter, called with a fresh projection per candidate pass. */ available(session: ClientSessionContext): boolean - /** The command's UI behavior (this phase: popupSelect only). */ + /** The command's UI behavior. */ readonly ui: CommandUiSpec } @@ -68,7 +86,7 @@ export interface CommandDecoration { readonly name: string /** Capability filter, called with a fresh projection per bare invocation. */ available(session: ClientSessionContext): boolean - /** The bare-invocation UI (this phase: popupSelect only). */ + /** The bare-invocation UI. */ readonly ui: CommandUiSpec } diff --git a/packages/client/ui-commands/src/client/index.ts b/packages/client/ui-commands/src/client/index.ts index 26e8a32e09..0c1240ab76 100644 --- a/packages/client/ui-commands/src/client/index.ts +++ b/packages/client/ui-commands/src/client/index.ts @@ -27,7 +27,8 @@ export { filterOptions, PopupSelectController } from './popup.ts' export type { PopupSelectDeps, PopupSpec, PopupState, TokenSegment } from './popup.ts' export type { PopupSelectInjected, PopupSelectViewProps } from './PopupSelectView.tsx' export type { - CommandContribution, CommandDecoration, CommandUiContract, CommandUiSpec, SelectConfirmation, SelectOption, + ActionSpec, CommandContribution, CommandDecoration, CommandUiContract, CommandUiSpec, PopupSelectSpec, + SelectConfirmation, SelectOption, } from './contract.ts' export type { CommandKey } from './locales.ts' diff --git a/packages/client/ui-commands/src/client/service.ts b/packages/client/ui-commands/src/client/service.ts index f14f13f0be..59f885bb6d 100644 --- a/packages/client/ui-commands/src/client/service.ts +++ b/packages/client/ui-commands/src/client/service.ts @@ -228,22 +228,22 @@ export class CommandUiRuntime extends Service implements CommandUiContract { return key !== undefined && command.description === en[key] ? this.t(key) : command.description } - /** Decision table, menu column: contribution/decorated-host → popup; host input → claim; host bare → detached execute. */ + /** Decision table, menu column: contribution/decorated-host → popup or action; host input → claim; host bare → detached execute. */ private dispatch(pick: InputTriggerPick): PickOutcome { const name = pick.candidate.name const contribution = this.live.contributions.get(name) if (contribution !== undefined && contribution.available(pick.session)) { - this.openPopup(name, contribution.ui, pick.session, { via: 'menu', span: pick.span }) + this.invoke(name, contribution.ui, pick.session, { via: 'menu', span: pick.span }) return 'handled' } const desc = this.directory.resolve(pick.session.sessionId, name) if (desc === undefined) return undefined // snapshot swapped between menu and pick → miss - // A decoration replaces the HOST row's bare invocation with its popup; - // it decorates only a resolvable host command (checked above), never - // manufactures one, and never touches the argument claim below. + // A decoration replaces the HOST row's bare invocation with its popup or + // action; it decorates only a resolvable host command (checked above), + // never manufactures one, and never touches the argument claim below. const decoration = this.live.decorations.get(name) if (decoration !== undefined && decoration.available(pick.session)) { - this.openPopup(name, decoration.ui, pick.session, { via: 'menu', span: pick.span }) + this.invoke(name, decoration.ui, pick.session, { via: 'menu', span: pick.span }) return 'handled' } if (desc.input !== undefined) return { claim: this.leadingClaim(desc, pick.session) } @@ -258,7 +258,7 @@ export class CommandUiRuntime extends Service implements CommandUiContract { private matchSpace(session: ClientSessionContext, token: string): PickOutcome { if (!token.startsWith('/')) return undefined const name = token.slice(1) - if (this.live.contributions.has(name)) return undefined // popup kinds never claim on space + if (this.live.contributions.has(name)) return undefined // popup and action kinds never claim on space const desc = this.directory.resolve(session.sessionId, name) if (desc === undefined || desc.input === undefined) return undefined return { claim: this.leadingClaim(desc, session) } @@ -271,10 +271,11 @@ export class CommandUiRuntime extends Service implements CommandUiContract { * args-tolerant. * * Envelope policy: an enter submission carrying attachments resolves only - * through a command declaring attachment acceptance. Every other command route — - * popup, non-accepting claim, bare detached execute — throws the refusal - * so the machine surfaces one composer notice and the draft and attachments - * stay in place; nothing executes and nothing is dropped. + * through a command declaring attachment acceptance. Every other submitting + * route — popup, non-accepting claim, bare detached execute — throws the + * refusal so the machine surfaces one composer notice and the draft and + * attachments stay in place; nothing executes and nothing is dropped. An + * action submits nothing and runs regardless. */ private async matchEnter( session: ClientSessionContext, @@ -295,8 +296,8 @@ export class CommandUiRuntime extends Service implements CommandUiContract { const contribution = this.live.contributions.get(name) if (contribution !== undefined && contribution.available(session)) { if (!bare) return undefined - if (envelope.attachments > 0) refuseAttachments() - this.openPopup(name, contribution.ui, session, { via: 'enter', token }) + if (envelope.attachments > 0 && contribution.ui.kind !== 'action') refuseAttachments() + this.invoke(name, contribution.ui, session, { via: 'enter', token }) return 'handled' } await this.directory.ensureReady(session.sessionId, signal) @@ -307,8 +308,8 @@ export class CommandUiRuntime extends Service implements CommandUiContract { if (bare) { const decoration = this.live.decorations.get(name) if (decoration !== undefined && decoration.available(session)) { - if (envelope.attachments > 0) refuseAttachments() - this.openPopup(name, decoration.ui, session, { via: 'enter', token }) + if (envelope.attachments > 0 && decoration.ui.kind !== 'action') refuseAttachments() + this.invoke(name, decoration.ui, session, { via: 'enter', token }) return 'handled' } } @@ -323,13 +324,21 @@ export class CommandUiRuntime extends Service implements CommandUiContract { return 'handled' } - /** Open the session's popup for one contribution or decoration (menu pick / bare enter). */ - private openPopup( + /** + * Invoke one contribution or decoration (menu pick / bare enter): open the + * session's popup, or consume the token and run the action. + */ + private invoke( name: string, ui: CommandContribution['ui'], session: ClientSessionContext, segment: TokenSegment, ): void { + if (ui.kind === 'action') { + this.consumeVia(session.sessionId, segment) + ui.run(session) + return + } const actx = this.scopeFor(session.sessionId) if (actx === undefined) return this.popupFor(actx).open(name, ui, session, segment) diff --git a/packages/client/ui-commands/tests/service.client.spec.ts b/packages/client/ui-commands/tests/service.client.spec.ts index f387ebffc1..fd48118587 100644 --- a/packages/client/ui-commands/tests/service.client.spec.ts +++ b/packages/client/ui-commands/tests/service.client.spec.ts @@ -14,7 +14,7 @@ import { createScope, scopeOf } from '@deepseek-ai/dsh-api-session-controller/cl import type { SessionId } from '@deepseek-ai/dsh-session/types' import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import type { ClientSessionContext, ConsumeTokenRequest, InputTriggerPick, InputTriggerSource, SubmitAttachment } from '@deepseek-ai/dsh-client-ui-input-trigger/client' -import type { CommandContribution, CommandDecoration, CommandUiSpec, SelectOption } from '../src/client/contract.ts' +import type { CommandContribution, CommandDecoration, PopupSelectSpec, SelectOption } from '../src/client/contract.ts' import type { CommandDescriptor } from '../src/client/directory.ts' import { CommandUiRuntime } from '../src/client/service.ts' @@ -157,7 +157,7 @@ function menuPick(source: InputTriggerSource, name: string, session: ClientSessi return source.onPick(pick) } -const themeUi = (over: Partial = {}): CommandUiSpec => ({ +const themeUi = (over: Partial = {}): PopupSelectSpec => ({ kind: 'popupSelect', options: () => Promise.resolve([{ id: 'dark', label: 'Dark' }]), onSelect: () => undefined, @@ -384,6 +384,43 @@ describe('dispatch (menu column)', () => { expect(executeCalls).toEqual([]) }) + it('action decoration: a menu pick consumes the span and runs the callback without executing', async () => { + const { command, source, mint, warm, executeCalls } = await bench() + const scope = mint('s1') + const consumes: ConsumeTokenRequest[] = [] + scope.ctx.on('slash/input-consume-token', (r) => { + consumes.push(r) + return true + }) + const run = vi.fn() + command.decorate({ name: 'plan', available: () => true, ui: { kind: 'action', run } }) + await warm(proj('s1')) + expect(menuPick(source, 'plan', proj('s1'), 5)).toBe('handled') + expect(consumes).toEqual([{ guard: { kind: 'span', span: { start: 0, end: 5, draftRev: 3 } } }]) + expect(run).toHaveBeenCalledWith(proj('s1')) + expect(executeCalls).toEqual([]) + expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(false) + }) + + it('action decoration: a bare enter runs even with attachments; an argued line bypasses it', async () => { + const { command, source, mint, warm } = await bench() + const scope = mint('s1') + const consumes: ConsumeTokenRequest[] = [] + scope.ctx.on('slash/input-consume-token', (r) => { + consumes.push(r) + return true + }) + const run = vi.fn() + command.decorate({ name: 'plan', available: () => true, ui: { kind: 'action', run } }) + await warm(proj('s1')) + await expect(source.matchEnter!(proj('s1'), '/plan', new AbortController().signal, { attachments: 1 })).resolves.toBe('handled') + expect(consumes).toEqual([{ guard: { kind: 'bare-token', token: '/plan' } }]) + expect(run).toHaveBeenCalledTimes(1) + // An argued line never consults the decoration. + await expect(source.matchEnter!(proj('s1'), '/plan later', new AbortController().signal, { attachments: 0 })).resolves.not.toBe('handled') + expect(run).toHaveBeenCalledTimes(1) + }) + it('host bare → consume-token span guard on the session scope + detached execute', async () => { const { source, mint, warm, executeCalls, executions } = await bench() const scope = mint('s1') diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index a1eea2e97a..56e93e77a2 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 19a210bb200dae359920e85ff944b68242f45b4b -README.zh.md: c5a56871eb99607c924b2b2e048de72d918e6a8b +README.md: 7d5f370644f90f57ce1c27c59becf47c4cf2e684 +README.zh.md: e7c5204b763aad92dc057d122537d592952afcd5 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 19a210bb20..7d5f370644 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -44,7 +44,7 @@ View selection is deterministic: a registered persisted selection wins, otherwis The shell reads the persisted View preference before rendering when a Session first binds or a cached Session becomes current, activates the registered preferred View or Chat fallback, and activates later tab or focus selections before committing them to the store. A blank Session still omits the `conversation.view` slot; no unselected target is activated. -The resident composer survives no-Session and Session transitions. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text, and durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. +The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text, and durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order in `pendingSubmissions`; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; queue and history observation by that `rpcId` retires the echo once. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their attachment ids through admission and Session scope disposal. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Continuable subagents disable attachment intake and skip local echoes because their transport does not preserve the browser request id. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index c5a56871eb..e7c5204b76 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -44,7 +44,7 @@ View 选择规则固定:有效且已注册的持久化选择优先,其次是 Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染前读取持久化 View 偏好,激活已注册的偏好 View 或 Chat fallback,并在后续 tab 或 focus 选择写入 store 前先激活对应 target。blank Session 仍不渲染 `conversation.view` slot;未选中的 target 不会激活。 -常驻 composer 在无 Session 与有 Session 之间保持挂载。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态展示字面发送文本,持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 +常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态展示字面发送文本,持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript,繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。prompt 复用提交 `requestId`;queue 或历史以同一 `rpcId` 被观察后,回显只退休一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation service 在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。continuable 子代理禁用附件入口,也不创建本地回显,因为其 transport 不保留浏览器 request id。 diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 3025bbe691..5e2e906482 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -469,7 +469,7 @@ export const InputBar = memo(function InputBar({ onKeyDown={workspaceTrigger ? onWorkspaceKeyDown : undefined} style={hint === null ? undefined : { '--dsh-composer-hint': JSON.stringify(hint) } as CSSProperties} /> - {empty && !claimActive && ( + {draft === '' && attachments.length === 0 && !claimActive && (
{placeholderText}
diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index 81d25084a9..d2a3064a58 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -270,6 +270,49 @@ function writeDraft(shell: SessionInputShell, text: string): void { act(() => { shell.setDraft(text) }) } +describe('composer placeholder visibility', () => { + it.each([' ', ' ', '\t', '\n'])('hides for whitespace %j and returns after deletion', async (draft) => { + const { view, shell, textarea, button, sink, props } = bench() + const placeholder = () => view.container.querySelector('[data-composer-placeholder]') + expect(placeholder()).not.toBeNull() + writeDraft(shell, draft) + expect(placeholder()).toBeNull() + expect(button.disabled).toBe(true) + fireEvent.keyDown(textarea, { key: 'Enter', keyCode: 13 }) + await act(async () => {}) + expect(sink).not.toHaveBeenCalled() + fireEvent.blur(textarea) + view.rerender() + fireEvent.focus(textarea) + expect(placeholder()).toBeNull() + writeDraft(shell, '') + expect(placeholder()).not.toBeNull() + }) + + it('hides for pasted spaces and restores after clearing', async () => { + const { view, shell, textarea } = bench() + fireEvent.paste(textarea, { + clipboardData: { items: [], getData: () => ' ' }, + }) + await vi.waitFor(() => { expect(shell.snapshot.draft).toBe(' ') }) + expect(view.container.querySelector('[data-composer-placeholder]')).toBeNull() + writeDraft(shell, '') + expect(view.container.querySelector('[data-composer-placeholder]')).not.toBeNull() + }) + + it('keeps whitespace hidden through composition and rerender', () => { + const { view, shell, textarea, props } = bench() + fireEvent.compositionStart(textarea) + writeDraft(shell, ' ') + expect(view.container.querySelector('[data-composer-placeholder]')).toBeNull() + view.rerender() + fireEvent.compositionEnd(textarea, { data: ' ' }) + expect(view.container.querySelector('[data-composer-placeholder]')).toBeNull() + writeDraft(shell, '') + expect(view.container.querySelector('[data-composer-placeholder]')).not.toBeNull() + }) +}) + describe('image draft rail', () => { it('collects clipboard files while preserving text from a mixed paste', async () => { const addFiles = vi.fn(() => null) diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index 9cc543ee66..a72595b84a 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-deliverables/README.md -README.md: ae42abb5a69d29332bd7cfd6d2d2a1191381e79a -README.zh.md: 664dfa3cd2e0d526b43464a80290604a8ecf958c +README.md: 4886a5817e6118ef76d5e7b49ed7fe3ed319650f +README.zh.md: 43c68d4563f9ec32bb15fdd3df9a2e21225ebc13 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index ae42abb5a6..4886a5817e 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -36,7 +36,7 @@ The `present` tool row shows running, delivered, failed, or interrupted status; ### The row -The row uses CSS container-width bands to show a responsive prefix of up to six file chips. Flexbox shrinks and ellipsizes basename text, while CSS selects the matching localized `+ N files` label for omitted paths; the full path remains available as the title, and the row performs no JavaScript layout observation or horizontal scrolling. +The “Files changed” row lists successful file-tool mutations; final file deliveries require `present`. It uses CSS container-width bands to show a responsive prefix of up to six file chips. Flexbox shrinks and ellipsizes basename text, while CSS selects the matching localized `+ N files` label for omitted paths; the full path remains available as the title, and the row performs no JavaScript layout observation or horizontal scrolling. ### Inline-code links diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 664dfa3cd2..43c68d4563 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -36,7 +36,7 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交 ### 该行 -该行通过 CSS 容器宽度档位响应式展示至多六个文件标签项。Flexbox 负责收缩文件名并用 ellipsis 省略,CSS 为未展示路径选择匹配的本地化 `+ N 个文件` 标签;完整路径仍保留在 `title` 中,该行不执行 JavaScript 布局观察,也不提供横向滚动。 +“本轮文件改动”行列出成功的文件工具修改;最终文件交付需要调用 `present`。该行通过 CSS 容器宽度档位响应式展示至多六个文件标签项。Flexbox 负责收缩文件名并用 ellipsis 省略,CSS 为未展示路径选择匹配的本地化 `+ N 个文件` 标签;完整路径仍保留在 `title` 中,该行不执行 JavaScript 布局观察,也不提供横向滚动。 ### 行内代码链接 diff --git a/packages/client/ui-deliverables/src/client/locales.ts b/packages/client/ui-deliverables/src/client/locales.ts index d9b468e15c..c206f2d23a 100644 --- a/packages/client/ui-deliverables/src/client/locales.ts +++ b/packages/client/ui-deliverables/src/client/locales.ts @@ -18,7 +18,7 @@ export const zh = { 'row.stopped': '已中断', 'row.inspect': '查看调用', 'presented.open': '在默认程序中打开 {name}', - 'produced.label': '产物', + 'produced.label': '本轮文件改动', 'produced.moreOne': '+ 1 个文件', 'produced.more': '+ {count} 个文件', 'produced.open': '打开 {name}', @@ -39,7 +39,7 @@ export const en: Record = { 'row.stopped': 'Interrupted', 'row.inspect': 'Inspect call', 'presented.open': 'Open {name} in default app', - 'produced.label': 'Produced', + 'produced.label': 'Files changed', 'produced.moreOne': '+ 1 file', 'produced.more': '+ {count} files', 'produced.open': 'Open {name}', diff --git a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx index 9b2d38caaa..15c95017f3 100644 --- a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx @@ -426,7 +426,7 @@ describe('ProducedFiles row', () => { const openFile = vi.fn<(path: string) => void>() const view = render() - expect(view.getByText('产物')).toBeTruthy() + expect(view.getByText('本轮文件改动')).toBeTruthy() const row = view.container.querySelector('[data-produced-files-row]') if (!(row instanceof HTMLElement)) throw new Error('produced row missing') expect(within(row).getAllByRole('button')).toHaveLength(6) @@ -595,7 +595,7 @@ describe('presented files', () => { expect(view.queryByRole('link')).toBeNull() fireEvent.click(view.getByRole('button', { name: 'Open report-0.docx in default app' })) expect(props.openPresented).toHaveBeenCalledWith('child-session', 2, 0) - expect(view.queryByText('Produced')).toBeNull() + expect(view.queryByText('Files changed')).toBeNull() }) }) @@ -617,7 +617,7 @@ it.each([{}, { turn: '1', callId: 'bad', files: [] }, const owner = tailOwner(deliverablesOf(value), 5) const matched = selectDeliverables(owner)! const view = render() - expect(view.getByText('Produced')).toBeTruthy() + expect(view.getByText('Files changed')).toBeTruthy() expect(view.queryByText('Deliverables')).toBeNull() }) diff --git a/packages/client/ui-message-feedback/README.i18n.yaml b/packages/client/ui-message-feedback/README.i18n.yaml index 6570ef5e3e..4e2c01b49d 100644 --- a/packages/client/ui-message-feedback/README.i18n.yaml +++ b/packages/client/ui-message-feedback/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-message-feedback/README.md -README.md: 171a812acc836eec243eb9f596270359c0a3aa9c -README.zh.md: e34eba9014779fc27bbd5a7ef071a086e8982f45 +README.md: 8ffcf87a59ec95fe783a0379423b93d4bf965a3e +README.zh.md: a29448eaaddf0f089d8fc12c1315732fd13fc304 diff --git a/packages/client/ui-message-feedback/README.md b/packages/client/ui-message-feedback/README.md index 171a812acc..8ffcf87a59 100644 --- a/packages/client/ui-message-feedback/README.md +++ b/packages/client/ui-message-feedback/README.md @@ -1,5 +1,5 @@ --- -description: "Per-message feedback for the Web GUI: the Like/Dislike pair and optional note in the finalized assistant message's action row; for users and maintainers of the feedback experience." +description: "The Web feedback surface: the Like/Dislike pair in the finalized assistant message's action row, the feedback dialog behind Dislike and `/feedback`, and the acknowledgement toast; for users and maintainers of the feedback experience." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package adds per-message feedback to the Web GUI: a Like/Dislike pair plus an optional note, contributed as the `feedback` entry of the finalized assistant message's action strip. It renders on the closing assistant message of each turn — earlier steps of a multi-step turn produce tool rows rather than a rateable body. One controller per Session backs every message control in that Session, so a single list read seeds the whole transcript. Ratings and notes are log-only Session events: they never enter model context. Deletion retracts the current item without erasing its earlier log entries. +This package is the Web GUI's feedback surface: the Like/Dislike pair in the finalized assistant message's action strip, the feedback dialog with its acknowledgement toast in the composer overlay, and a decoration that opens the dialog from a bare `/feedback`. Like records at once and shows the toast; Dislike opens the dialog, which collects a category and an optional description. One surface per Session backs every entry, so a single list read seeds the whole transcript and one dialog serves the Session and its messages. Ratings, categories, and notes are log-only Session events that never enter model context. ## Table of Contents @@ -25,11 +25,11 @@ This package adds per-message feedback to the Web GUI: a Like/Dislike pair plus ## Use this package -Mount this plugin alongside `ui-conversation`; the Like/Dislike pair then appears in the action row of each turn's closing assistant message, between copy and branch. Clicking the recorded rating retracts the feedback; switching sides carries the existing note forward. The note editor is a dialog popover anchored under its trigger, so the row keeps its single line whether the editor is open or closed. +Mount this plugin alongside `ui-conversation` and `ui-commands`; the Like/Dislike pair then appears in the action row of each turn's closing assistant message, between copy and branch, and the Feedback row of the composer menu opens the dialog. A recorded rating shows the filled glyph and stays visible without hover. Like records immediately and the toast thanks the user for the feedback. Dislike opens the dialog: seven category chips and a detail box, both optional; Submit records a negative judgment carrying whatever was filled in, and the conversation log travels with every feedback event. Clicking the recorded rating retracts it. A bare `/feedback`, picked from the menu or typed and sent without text, opens the same dialog for the Session; `/feedback ` keeps the Host command path and its acknowledgement row. ### Failures -A rating or list-load failure shows inline in the row; a note-save failure shows inside the popover, which stays open so the draft can be corrected. Only finalized messages reach the slot — an interruption-frozen partial carries no `messageId` and therefore no feedback controls. +A rating or list-load failure shows inline in the row; a submission failure shows inside the dialog, which stays open so the draft can be corrected. Only finalized messages reach the message entry — an interruption-frozen partial carries no `messageId` and therefore no feedback controls. ----- @@ -39,7 +39,9 @@ A rating or list-load failure shows inline in the row; a note-save failure shows
Implementation internals — click to expand -The package contributes the `feedback` entry (order 10) of `conversation.chat.assistant-actions`, declared by ui-conversation and rendered inside the finalized assistant message's IconActions row. One `MessageFeedbackController` per Session backs every message control in that Session, so a single `messageFeedback.list` read seeds the whole transcript; the read is deferred to the first hover or focus rather than fired on mount. Mutations go through `ctx.remote.messageFeedback`; the Host owns per-item compare-and-set. Every `put` and `delete` carries the `version` this controller last observed, and a `version-conflict` reply carries the authoritative item, so a lost race reconciles from the reply itself instead of refetching. Mutations serialize per Session, so a queued operation always compares against the committed version. +The package contributes the `feedback` entry (order 10) of `conversation.chat.assistant-actions`, declared by ui-conversation and rendered inside the finalized assistant message's IconActions row, and the `feedback-dialog` entry (order 2) of `conversation.input.overlay`, which renders the Modal and Toast primitives through body portals and centers the toast over the composer card it mounts inside. The `/feedback` decoration is an `action` registered through `ctx.commandUi.decorate`, so a menu pick or a bare Enter consumes the trigger token and opens the dialog while an argued line still reaches the Host command. + +Per Session, one `MessageFeedbackController` backs every message control and one `FeedbackDialogController` owns the dialog draft, the submission, and the toast sequence. The message controller reads `messageFeedback.list` once, deferred to the first hover or focus rather than fired on mount, and serializes mutations so each carries the version last observed; a `version-conflict` reply carries the authoritative item and reconciles the view without refetching. `toggle` reports the rating now committed, so the row acknowledges a recorded Like and not a retraction. The dialog controller submits by target: a message target puts a negative judgment with the dialog's note and category through the message controller, and the Session target records through `ctx.remote.sessionFeedback`. Success closes the draft and raises the toast; a late success from a superseded draft raises the toast without closing the new draft; a failure keeps the draft open with its code.
@@ -48,10 +50,12 @@ The package contributes the `feedback` entry (order 10) of `conversation.chat.as ## Further Exploration -Read these pages when the feedback surface is not enough. They move from the browser strip to the Session-log backend and the conversation shell. +Read these pages when the feedback surface is not enough. They move from the browser strip to the Session-log backends and the conversation shell. - [dsh-message-feedback](../../feedback/message-feedback/README.md) — the Session-log backend that owns per-item compare-and-set and persistence. -- [ui-conversation](../ui-conversation/README.md) — declares the assistant-actions strip and renders the action row. +- [dsh-command-feedback](../../feedback/command-feedback/README.md) — the `/feedback` command, the `sessionFeedback` Remote, and the category taxonomy. +- [ui-commands](../ui-commands/README.md) — the command decoration contract the `/feedback` row goes through. +- [ui-conversation](../ui-conversation/README.md) — declares the assistant-actions strip and the composer overlay. - [Client package map](../README.md) — adjacent browser UI packages. ----- @@ -59,7 +63,7 @@ Read these pages when the feedback surface is not enough. They move from the bro ## Model Experience -None, as ratings and notes are log-only events, not model input. Optional Session-log delivery uses request metadata rather than model context. +None, as ratings, categories, and notes are log-only events, not model input. Optional Session-log delivery uses request metadata rather than model context. #### KV Cache effect @@ -72,7 +76,8 @@ None; feedback mutations leave the model-visible history unchanged. These limits define the current feedback surface. They are current package constraints, not a general rating comparison or a task backlog. -- **Note size is a Host policy** — the deployment configures `maxNoteBytes` (8192 in the Web bundle) and the Host rejects an oversized note with `note-too-large`. The editor does not pre-check the limit, so an oversized note fails on save rather than while typing. +- **Note size is a Host policy** — the deployment configures `maxNoteBytes` (8192 in the Web bundle) and the Host rejects an oversized note with `note-too-large`. The dialog does not pre-check the limit, so an oversized description for a message fails on submit rather than while typing; a Session remark has no bound. +- **No note on a Like** — only the Dislike dialog collects a category and description; a Like records the bare judgment. - **No cross-tab push** — a second tab's rating becomes visible on reconnect or on the next conflict reply, not immediately; the controller does not consume feedback log events. - **Chat view only** — the trajectory and waterfall views render no feedback controls even though their assistant nodes carry the same `messageId`. @@ -86,4 +91,4 @@ None. -**Runtime invariant:** No companion is published. The plugin owns one slot registration and one per-session controller map, both released by the same effect disposer. The lifecycle spec proves the registration is withdrawn and every controller is dropped when the owning fiber is disposed, so no second authority exists to check at runtime. +**Runtime invariant:** No companion is published. The plugin owns two slot registrations, one command decoration, and one per-session controller-pair map, all released by the plugin fiber's effect disposers. The lifecycle spec proves the registrations are withdrawn and every controller pair is dropped when the owning fiber is disposed, so no second authority exists to check at runtime. diff --git a/packages/client/ui-message-feedback/README.zh.md b/packages/client/ui-message-feedback/README.zh.md index e34eba9014..a29448eaad 100644 --- a/packages/client/ui-message-feedback/README.zh.md +++ b/packages/client/ui-message-feedback/README.zh.md @@ -1,5 +1,5 @@ --- -description: "Web GUI 的逐消息反馈:已定稿助手消息动作行中的 Like/Dislike 对与可选备注;供反馈体验的用户与维护者阅读。" +description: "Web 反馈界面:已定稿助手消息动作行中的 Like/Dislike 对、点踩与 `/feedback` 背后的反馈弹窗,以及确认 toast;供反馈体验的用户与维护者阅读。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包为 Web GUI 增加逐消息反馈:一对 Like/Dislike 按钮加一个可选备注,作为已定稿助手消息动作条的 `feedback` 条目贡献。它渲染在每个轮次的收尾助手消息上——多步骤轮次中较早的步骤产出工具行而非可评分正文。每个 Session 一个控制器支撑该 Session 内所有消息的控件,因此一次列表读取即可填充整段对话。评分与备注是仅写日志的 Session 事件:它们绝不进入模型上下文。删除会撤回当前条目,但不会抹除早先的日志记录。 +本包是 Web GUI 的反馈界面:已定稿助手消息动作条中的 Like/Dislike 对、输入框浮层中的反馈弹窗及其确认 toast,以及让不带文本的 `/feedback` 打开弹窗的装饰。点赞立即记录并显示 toast;点踩打开弹窗,弹窗收集分类与可选描述。每个 Session 一个 surface 支撑所有条目,因此一次列表读取即可填充整段对话,一个弹窗同时服务 Session 与其消息。评分、分类与备注是仅写日志的 Session 事件,绝不进入模型上下文。 ## 目录 @@ -25,11 +25,11 @@ kind: "package-reference" ## 使用本包 -与 `ui-conversation` 一起挂载本插件;Like/Dislike 对随即出现在每个轮次收尾助手消息的动作行中,位于复制与分支之间。再次点击已记录的评分会撤回反馈;切换到另一侧会保留既有备注。备注编辑器是一个锚定在其触发按钮下方的对话框浮层,因此无论编辑器是否打开,该行都保持单行。 +与 `ui-conversation`、`ui-commands` 一起挂载本插件;Like/Dislike 对随即出现在每个轮次收尾助手消息的动作行中,位于复制与分支之间,输入框菜单里的「反馈」行则打开弹窗。已记录的评分显示实心图标,不需要悬停也一直可见。点赞立即记录,toast 显示感谢反馈。点踩打开弹窗:七个分类标签和一个详情框,都可不填;提交会记录一条带上所填内容的差评,对话日志随每个反馈事件一起投递。再次点击已记录的评分会撤回它。不带文本的 `/feedback`,无论是从菜单选中还是直接输入后发送,都会为 Session 打开同一个弹窗;`/feedback ` 仍走宿主命令路径并显示确认行。 ### 失败 -评分或列表加载失败在行内展示;备注保存失败在浮层内展示,面板保持打开以便修正草稿。只有已定稿的消息能到达该槽位——被中断冻结的部分输出不带 `messageId`,因此没有反馈控件。 +评分或列表加载失败在行内展示;提交失败在弹窗内展示,弹窗保持打开以便修正草稿。只有已定稿的消息能到达消息条目——被中断冻结的部分输出不带 `messageId`,因此没有反馈控件。 ----- @@ -39,7 +39,9 @@ kind: "package-reference"
实现细节——点击展开 -本包贡献 `conversation.chat.assistant-actions` 的 `feedback` 条目(order 10),由 ui-conversation 声明并渲染在已定稿助手消息的 IconActions 行内。每个 Session 一个 `MessageFeedbackController` 支撑该 Session 内所有消息的控件,因此一次 `messageFeedback.list` 读取即可填充整段对话;该读取延迟到首次 hover 或 focus 才发起,而非挂载时触发。变更经 `ctx.remote.messageFeedback` 提交,按条目的比较并交换由宿主负责。每次 `put` 与 `delete` 都携带本控制器最后观察到的 `version`;`version-conflict` 响应带回权威条目,因此竞争失败时直接用该响应本身对账,无需重新拉取。变更按 Session 串行,排队中的操作总是与已提交的版本比较。 +本包贡献 `conversation.chat.assistant-actions` 的 `feedback` 条目(order 10),由 ui-conversation 声明并渲染在已定稿助手消息的 IconActions 行内;同时贡献 `conversation.input.overlay` 的 `feedback-dialog` 条目(order 2),它通过 body portal 渲染 Modal 与 Toast 基元,并让 toast 以其所在的输入框卡片为中心。`/feedback` 装饰是经 `ctx.commandUi.decorate` 注册的 `action`,因此菜单选中或不带参数的回车会消费触发 token 并打开弹窗,而带参数的命令行仍到达宿主命令。 + +每个 Session 有一个 `MessageFeedbackController` 支撑所有消息控件,以及一个 `FeedbackDialogController` 拥有弹窗草稿、提交与 toast 序号。消息控制器只读取一次 `messageFeedback.list`,且延迟到首次 hover 或 focus 才发起,而非挂载时触发;变更串行执行,每次都携带最后观察到的版本,`version-conflict` 响应带回权威条目,据此对账视图而不重新拉取。`toggle` 会报告提交后的评分,因此该行只对记录成功的点赞做确认,撤回不做。弹窗控制器按目标提交:消息目标通过消息控制器 put 一条带弹窗备注与分类的差评,Session 目标通过 `ctx.remote.sessionFeedback` 记录。成功会关闭草稿并弹出 toast;被替换的旧草稿迟到的成功只弹 toast、不关闭新草稿;失败让草稿保持打开并带上失败码。
@@ -48,10 +50,12 @@ kind: "package-reference" ## 进一步探索 -当反馈面不够用时阅读以下页面。它们从浏览器条带进入 Session 日志后端与会话外壳。 +当反馈界面不够用时阅读以下页面。它们从浏览器条带进入 Session 日志后端与会话外壳。 - [dsh-message-feedback](../../feedback/message-feedback/README.zh.md)——拥有按条目比较并交换与持久化的 Session 日志后端。 -- [ui-conversation](../ui-conversation/README.zh.md)——声明助手动作条并渲染动作行。 +- [dsh-command-feedback](../../feedback/command-feedback/README.zh.md)——`/feedback` 命令、`sessionFeedback` Remote 与分类表。 +- [ui-commands](../ui-commands/README.zh.md)——`/feedback` 行所经过的命令装饰约定。 +- [ui-conversation](../ui-conversation/README.zh.md)——声明助手动作条与输入框浮层。 - [客户端包映射](../README.zh.md)——相邻的浏览器 UI 包。 ----- @@ -59,7 +63,7 @@ kind: "package-reference" ## 模型体验 -无。评分与备注是仅写日志的事件,不是模型输入。可选的 Session 日志投递使用请求元数据,而非模型上下文。 +无。评分、分类与备注是仅写日志的事件,不是模型输入。可选的 Session 日志投递使用请求元数据,而非模型上下文。 #### KV Cache 影响 @@ -70,9 +74,10 @@ kind: "package-reference" -这些限制界定了当前反馈表面。它们是当前包约束,不是通用评分对比或任务积压。 +这些限制界定了当前反馈界面。它们是当前包约束,不是通用评分对比或任务积压。 -- **备注大小是宿主策略**——部署方配置 `maxNoteBytes`(Web bundle 中为 8192),超长备注由宿主以 `note-too-large` 拒绝。编辑器不预先校验该上限,因此超长备注在保存时才失败,而不是在输入过程中。 +- **备注大小是宿主策略**——部署方配置 `maxNoteBytes`(Web bundle 中为 8192),超长备注由宿主以 `note-too-large` 拒绝。弹窗不预先校验该上限,因此针对消息的超长描述在提交时才失败,而不是在输入过程中;Session 级备注没有上限。 +- **点赞不带备注**——只有点踩弹窗收集分类与描述;点赞只记录判断本身。 - **无跨标签页推送**——另一个标签页的评分要等到重连或下一次冲突响应才可见,不会立即出现;控制器不消费反馈日志事件。 - **仅限对话视图**——trajectory 与 waterfall 视图不渲染反馈控件,尽管它们的助手节点也带有相同的 `messageId`。 @@ -86,4 +91,4 @@ kind: "package-reference" -**运行时不变式:** 不发布伴生入口。插件持有一个 slot 注册和按 Session 的 controller map,两者由同一 effect disposer 释放;生命周期测试已直接观察该关系。 +**运行时不变式:** 不发布伴生入口。插件持有两个 slot 注册、一个命令装饰和一个按 Session 的控制器对 map,全部由插件 fiber 的 effect disposer 释放;生命周期测试已直接观察注册被撤销、每对控制器被丢弃,因此运行时没有第二个可核对的权威。 diff --git a/packages/client/ui-message-feedback/package.json b/packages/client/ui-message-feedback/package.json index b73978ed20..d5fd7aed7e 100644 --- a/packages/client/ui-message-feedback/package.json +++ b/packages/client/ui-message-feedback/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-message-feedback", - "description": "Per-message feedback controls contributed to the assistant-message action strip, backed by the messageFeedback Host Remote", + "description": "The Web feedback surface: per-message Like/Dislike in the assistant-message action strip and the feedback dialog behind Dislike and /feedback, backed by the messageFeedback and sessionFeedback Host Remotes", "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" @@ -31,7 +31,8 @@ "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-conversation", - "@deepseek-ai/dsh-client-ui-renderer" + "@deepseek-ai/dsh-client-ui-renderer", + "@deepseek-ai/dsh-client-ui-commands" ], "platform": "web" } @@ -63,7 +64,10 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "@deepseek-ai/dsh-client-ui-chat": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^" + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-commands": "workspace:^", + "@deepseek-ai/dsh-command-feedback": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-message-feedback/src/client/FeedbackDialog.module.css b/packages/client/ui-message-feedback/src/client/FeedbackDialog.module.css new file mode 100644 index 0000000000..c5393561d5 --- /dev/null +++ b/packages/client/ui-message-feedback/src/client/FeedbackDialog.module.css @@ -0,0 +1,118 @@ +/* The card width leaves four categories on the first row and three on the + second. The doubled selector overrides the Modal card geometry. */ +.dialog.dialog { + gap: 38px; + width: min(488px, 100%); + border-radius: 18px; +} + +.categories { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: -14px; +} + +.chip { + height: 28px; + padding: 0 12px; + border: 0.5px solid var(--dsw-alias-border-l4); + border-radius: 14px; + corner-shape: round; + background: transparent; + color: var(--dsw-alias-label-primary); + font: inherit; + font-size: 13px; + line-height: 18px; + cursor: pointer; + transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease; +} + +.chip:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-primary); +} + +.chip:disabled { + cursor: default; + opacity: 0.4; +} + +.chip:focus-visible { + outline: 2px solid var(--dsw-alias-button-primary-fill); + outline-offset: 2px; +} + +.chipActive, +.chipActive:hover:not(:disabled) { + border-color: var(--dsw-alias-button-primary-fill); + background: var(--dsw-alias-button-primary-fill); + color: var(--dsw-alias-label-primary-foreground); +} + +/* The field starts at three short lines, grows with its text where supported, + and scrolls after the cap. */ +.detail { + display: block; + width: 100%; + min-height: 116px; + max-height: 280px; + box-sizing: border-box; + margin-top: 18px; + padding: 12px 14px; + border: 0.5px solid var(--dsw-alias-border-l4); + border-radius: 16px; + background: var(--dsw-alias-bg-layer-1); + color: var(--dsw-alias-label-primary); + font: inherit; + font-size: 14px; + line-height: 22px; + field-sizing: content; + resize: none; + transition: border-color 120ms ease, box-shadow 120ms ease; +} + +.detail::placeholder { + color: var(--dsw-alias-label-caption); +} + +.detail:focus { + outline: none; + border-color: var(--dsw-alias-border-l3); + box-shadow: 0 0 0 1px var(--dsw-alias-border-l3); +} + +.failure { + display: block; + margin-top: 8px; + color: var(--dsw-alias-label-tertiary); + font-size: 13px; + line-height: 20px; +} + +.submit { + width: 100%; + height: 44px; + border-radius: 18px; + font-size: 14px; + font-weight: 500; +} + +/* The design's ringed green check, not the warning tint the Toast icon seat defaults to. */ +.toastIcon { + display: grid; + place-items: center; + width: 18px; + height: 18px; + border: 1.5px solid var(--dsw-alias-state-success-primary); + border-radius: 50%; + corner-shape: round; + color: var(--dsw-alias-state-success-primary); +} + +@media (prefers-reduced-motion: reduce) { + .chip, + .detail { + transition: none; + } +} diff --git a/packages/client/ui-message-feedback/src/client/FeedbackDialog.tsx b/packages/client/ui-message-feedback/src/client/FeedbackDialog.tsx new file mode 100644 index 0000000000..c431848d2c --- /dev/null +++ b/packages/client/ui-message-feedback/src/client/FeedbackDialog.tsx @@ -0,0 +1,113 @@ +/** + * The feedback dialog and its acknowledgement toast, rendered as one entry + * of `conversation.input.overlay` so each Session owns exactly one of each. + * The Modal and the Toast both portal to `document.body`; the overlay slot + * only supplies the per-session controller and the composer card the toast + * centers over. + * @module @deepseek-ai/dsh-client-ui-message-feedback/client/FeedbackDialog + */ + +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { Button, IconCheckOutline16, Modal, Toast } from '@deepseek-ai/dsh-client-ui-primitives' +import type { FeedbackCategory } from '@deepseek-ai/dsh-command-feedback/types' +import type { FeedbackDialogProps } from './slots.ts' +import css from './FeedbackDialog.module.css' + +/** + * The chips in presentation order. A client bundle may not import a Host + * package's values, so the taxonomy is restated as a complete record of the + * `FeedbackCategory` union: a missing or foreign id is a compile error. + */ +const CATEGORY_CHIPS = { + 'task-result': true, + 'instruction-following': true, + 'product-interaction': true, + 'service-stability': true, + 'resource-cost': true, + 'security-privacy-permission': true, + 'other': true, +} satisfies Record +const CATEGORIES = Object.keys(CATEGORY_CHIPS) as FeedbackCategory[] + +/** Failure codes with their own copy; every other code reads the generic line. */ +const FAILURE_COPY: Partial> = { + 'version-conflict': 'error.conflict', + 'note-too-large': 'error.noteTooLarge', +} + +/** + * Render one Session's feedback dialog and toast. + * @param props - the dialog hook, the draft verbs, and the locale seat. + * @returns the modal while a target is open, the toast while one is showing. + */ +export function FeedbackDialog({ useDialog, edit, submit, dismiss, dismissToast, t }: FeedbackDialogProps) { + const state = useDialog(s => s) + // The toast centers over the composer card this entry renders inside of. + const probeRef = useRef(null) + const [card, setCard] = useState(null) + useLayoutEffect(() => { + setCard(probeRef.current?.closest('[data-composer-card]') ?? null) + }, []) + const toast = state.toast + const onToastDone = useCallback(() => { dismissToast(toast) }, [dismissToast, toast]) + // A toast retires with the entry that showed it: the Toast's own timer dies + // on unmount, and the Session's controller must not replay it on return. + useEffect(() => () => { dismissToast(toast) }, [dismissToast, toast]) + const failure = state.failure === null ? null : t(FAILURE_COPY[state.failure] ?? 'error.generic') + + return ( + <> + } + anchor={card} + onDone={onToastDone} + /> + )} + { void submit() }} + > + {state.submitting ? t('submitting') : t('submit')} + + )} + > +
+ {CATEGORIES.map(category => ( + + ))} +
+