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..f827897a1e 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: d79975e1fedb6efcd0e4ea83bc799bb158082e41 +2026-06-14-session-persistence.zh.md: f70b8005a254cc6a9ea8ada31c32e5552ee0c8b2 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..d79975e1fe 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md @@ -15,11 +15,11 @@ 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: -- **The canonical durable log persists every current `SessionEvent` losslessly.** In v2, one `assistant/message` or `assistant/attempt` embeds the exact timed provider stream for an attempt; `deriveMessages()` projects only the surface message. Dropping embedded stream members is tempting, but it loses replay, timing, usage, partial-failure, and diagnostic facts. Removing a complete event likewise requires dense renumbering because `seq = log.length` and `events[i].seq === i`; the [v1-to-v2 migration](2026-09-01-v2-embedded-assistant-streams.md) performs that rewrite explicitly rather than filtering the canonical log. +- **The canonical durable log persists every current `SessionEvent` losslessly.** One `assistant/message` or `assistant/attempt` embeds the exact timed provider stream for an attempt; `deriveMessages()` projects only the surface message. Dropping embedded stream members is tempting, but it loses replay, timing, usage, partial-failure, and diagnostic facts. Removing a complete event likewise requires dense renumbering because `seq = log.length` and `events[i].seq === i`; the [v1-to-v2 migration](2026-09-01-v2-embedded-assistant-streams.md) performs that rewrite explicitly rather than filtering the canonical log. - **Append-only; a crashed turn is closed, never truncated.** Flushed events are never rewritten. The [semantic checkpoint policy](../../../../packages/session/session-checkpoint-policy/README.md) drains the request before model dispatch, a recorded top-level call before tool dispatch, and the complete response/result batch after a step; the loop drains the final turn boundary. Because one interrupted turn may contain substantial valid work, persistence returns its contiguous, parseable events unmodified; the reader owns balancing — resume computes risk-classified error results for unanswered assistant calls, a missing `step/end`, and `turn/end` with `{ kind: 'interrupted' }` (`interruptedTurnClosers`) and appends them through its write handle, while read-only observers add the same closers in memory. The synthetic results keep resumed provider transcripts valid. Only the incomplete fragment of a torn final append is discarded — complete records recovered from it are durably rewritten by the write path before its first new append; a parse error or sequence gap in the committed prefix is corruption and makes the session unloadable. - **The file backend is canonical while the service remains extensible.** `dsh-session-persistence-jsonl` is the sole first-party provider and passes `runPersistenceContract`; the abstract service remains available to out-of-tree providers. The [JSONL-only persistence decision](../simplification/2026-08-30-jsonl-only-session-persistence.md) owns removal of the first-party database provider and its deliberate compatibility cut. - **Metadata is out-of-log.** Format version, cwd, and lineage are storage concerns, not replayable conversation state, so they live in a `SessionHeader` owned by `dsh-session` and attached to a `Session` via a new readonly `session.header` — never in `SessionEventMap`, never reaching `deriveMessages()`. `createdAt` is non-negative safe-integer Unix epoch milliseconds: live creation and persistence registration reject fractional values, and JSONL validates the decoded header. The alternative (a merge-extensible `session/meta` event as log line 0) was rejected: an in-log event would ride along with a seeded/forked session for free, but metadata is not replayable state, so the explicit out-of-log header boundary is the cleaner cost. (The header was originally split into an immutable `SessionHeader` plus a mutable `SessionSummary` whose union was `SessionMeta`; the mutable summary was later removed as dead state — see [Drop the mutable session summary](../../archived/simplification/2026-06-19-drop-mutable-session-summary.md).) @@ -29,7 +29,7 @@ Key durable, contested choices: Each key choice above records its rejected alternative where the choice is stated: a **stream-filtered canonical log** — loses attempt evidence, while removing events without an explicit migration breaks contiguous sequence numbers; **truncating a crashed turn** — silently destroys a long autonomous run's real work; an **in-log `session/meta` event as log line 0** — metadata is not replayable state; **finite fractional `createdAt` values** — have no producer and diverge from integer Unix-millisecond storage; **hard-injecting `sessionPersistence` into the loop** — would pend non-persistent demos forever. -Format versioning: the header carries a `version`; handles expose only `SESSION_FORMAT_VERSION = 2`. JSONL event-body reads compose the static v0-to-v1 and v1-to-v2 adjacent migration chain before returning a handle; the first edge owns bounded legacy normalization, while the second owns Assistant stream embedding and dense reference remapping. V0 remains at suffixless `session.jsonl[.zstd]`, while positive versions use immutable lowercase `session.vN.jsonl[.zstd]` names ([released Session migration](2026-08-31-released-session-format-migrations.md)). Current-generation append and flush are robust to partial trailing writes; a future provider or write-ahead log needs its own power-loss and recovery contract. +Format versioning: the header carries a `version`; handles expose only the logical format selected by `SESSION_FORMAT_VERSION` ([version authority](../../../../docs/session-format-status.md)). JSONL event-body reads compose the complete static adjacent migration chain before returning a handle; each edge owns its historical transformations. V0 remains at suffixless `session.jsonl[.zstd]`, while positive versions use immutable lowercase `session.vN.jsonl[.zstd]` names ([released Session migration](2026-08-31-released-session-format-migrations.md)). Current-generation append and flush are robust to partial trailing writes; a future provider or write-ahead log needs its own power-loss and recovery contract. ## Consequences 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..f70b8005a2 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,11 +15,11 @@ 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)是默认物理编码,也可通过配置使用原始行。 长期有效、存在争议的关键选择: -- **规范持久日志无损保留每个当前 `SessionEvent`。** 在 v2 中,一个 `assistant/message` 或 `assistant/attempt` 会嵌入该 attempt 的精确带时间 provider stream;`deriveMessages()` 只投影 surface message。删除嵌入 stream 成员看似诱人,但会丢失 replay、timing、usage、部分失败与诊断事实。移除完整事件同样需要密集重新编号,因为 `seq = log.length` 且 `events[i].seq === i`;[v1 到 v2 迁移](2026-09-01-v2-embedded-assistant-streams.zh.md)会显式执行该改写,而不是过滤规范日志。 +- **规范持久日志无损保留每个当前 `SessionEvent`。** 一个 `assistant/message` 或 `assistant/attempt` 会嵌入该 attempt 的精确带时间 provider stream;`deriveMessages()` 只投影 surface message。删除嵌入 stream 成员看似诱人,但会丢失 replay、timing、usage、部分失败与诊断事实。移除完整事件同样需要密集重新编号,因为 `seq = log.length` 且 `events[i].seq === i`;[v1 到 v2 迁移](2026-09-01-v2-embedded-assistant-streams.zh.md)会显式执行该改写,而不是过滤规范日志。 - **仅追加;崩溃的轮次被关闭,而非截断。** 已刷写的事件永不被重写。[语义检查点策略](../../../../packages/session/session-checkpoint-policy/README.zh.md)会在调用模型前排空请求、在调用工具前排空已记录的顶层调用,并在步骤结束后排空完整的响应/结果批次;循环则排空最终轮次边界。由于一个被中断的轮次可能包含大量有效工作,持久化会原样返回其连续、可解析的事件;配平是读方的职责——resume 会为未应答的 assistant 调用计算按风险分类的错误结果、补一个缺失的 `step/end`,以及带 `{ kind: 'interrupted' }` 的 `turn/end`(`interruptedTurnClosers`),并通过其写句柄追加它们,而只读观察方仅在内存中添加同样的收尾事件。合成结果保证恢复后的提供方 transcript(文本记录)仍然有效。只有撕裂的最终 append 中不完整的碎片会被丢弃——从中恢复的完整记录由写路径在第一次新 append 之前持久重写;已提交前缀中的解析错误或序号间隙,属于数据损坏,会使该会话不可加载。 - **文件后端为规范实现,服务保持可扩展。** `dsh-session-persistence-jsonl` 是唯一 first-party provider,并通过 `runPersistenceContract`;抽象服务继续供仓库外 provider 使用。[JSONL-only 持久化决策](../simplification/2026-08-30-jsonl-only-session-persistence.zh.md)负责 first-party 数据库 provider 的删除及其明确 compatibility cut。 - **元数据在日志之外。** 格式版本、cwd 和谱系是存储关注点,不是可回放的对话状态,因此它们存放在 `dsh-session` 拥有的 `SessionHeader` 中,并通过新的只读属性 `session.header` 附加到 `Session` 上——永远不进入 `SessionEventMap`,永远不到达 `deriveMessages()`。`createdAt` 是以 Unix epoch 毫秒表示的非负安全整数:运行时创建和持久化注册会拒绝小数值,JSONL 会验证解码后的 header。替代方案(一个可合并扩展的 `session/meta` 事件作为日志第 0 行)被否决:日志内事件会自然随 seed/fork 的会话携带,但元数据不是可回放状态,因此显式的日志外 header 边界是更清晰的取舍。(header 最初被拆分为不可变的 `SessionHeader` 加可变的 `SessionSummary`,二者的联合类型为 `SessionMeta`;可变 summary 后来因属于死状态而被移除——见 [移除可变会话摘要](../../archived/simplification/2026-06-19-drop-mutable-session-summary.md)。) @@ -29,7 +29,7 @@ Status: implemented 上述每个关键选择都在陈述处记录了被否决的替代方案:**过滤 stream 的规范日志**会丢失 attempt 证据,而未通过显式迁移移除事件会破坏连续序号;**截断崩溃的轮次**会静默销毁长时间自主运行中的真实工作;**日志内 `session/meta` 事件作为第 0 行**——元数据不是可回放状态;**有限的非整数 `createdAt` 值**没有生产方,且与整数 Unix 毫秒存储不一致;**将 `sessionPersistence` 硬注入循环**会让非持久化的演示永远挂起。 -格式版本控制:header 携带 `version`;句柄只暴露 `SESSION_FORMAT_VERSION = 2`。JSONL 的事件正文读取会在返回句柄前组合静态 v0-to-v1 与 v1-to-v2 相邻迁移链;第一条边负责有界 legacy normalization,第二条边负责 Assistant stream 嵌入与密集引用重映射。V0 保留无后缀的 `session.jsonl[.zstd]`,正版本则使用不可变的小写 `session.vN.jsonl[.zstd]` 名称([已发布 Session 迁移](2026-08-31-released-session-format-migrations.zh.md))。当前 generation 的 append 与 flush 能稳健处理不完整尾部写入;未来 provider 或 WAL 必须定义自己的断电与恢复约定。 +格式版本控制:header 携带 `version`;句柄只暴露由 `SESSION_FORMAT_VERSION` 选定的逻辑格式([版本真源](../../../../docs/session-format-status.zh.md))。JSONL 的事件正文读取会在返回句柄前组合完整的静态相邻迁移链;每条迁移边拥有自身的历史转换。V0 保留无后缀的 `session.jsonl[.zstd]`,正版本则使用不可变的小写 `session.vN.jsonl[.zstd]` 名称([已发布 Session 迁移](2026-08-31-released-session-format-migrations.zh.md))。当前 generation 的 append 与 flush 能稳健处理不完整尾部写入;未来 provider 或 WAL 必须定义自己的断电与恢复约定。 ## 后果 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 0d9f827360..822fd5f9f2 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: aeec2a36d0b1e498591ef509e2e9164f586ed60c -2026-07-30-session-end-seed-log-boundary.zh.md: ceba46a474c402230dbf215a2d53a41d3c027fc2 +2026-07-30-session-end-seed-log-boundary.md: 76f8904f75f6c4b5a1a6acab8d69ef2290be718e +2026-07-30-session-end-seed-log-boundary.zh.md: 4310654c841305a7f0de2f46434d0f839085c482 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 aeec2a36d0..76f8904f75 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 ceba46a474..4310654c84 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-04-draft-provider-endpoint-interrogation.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml index 14b9ebbac9..17747b2942 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md -2026-08-04-draft-provider-endpoint-interrogation.md: 462b8ba3e771018126aa6483a8cd72999361144d -2026-08-04-draft-provider-endpoint-interrogation.zh.md: c7612513b95bc3dc9736b9bfaf70cca26a776181 +2026-08-04-draft-provider-endpoint-interrogation.md: 469ab38b0f5609808a9df03237d4d21c0080d843 +2026-08-04-draft-provider-endpoint-interrogation.zh.md: 74c6bc7ed73572d5e99ea973c9ea94d22319cbdf diff --git a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md index 462b8ba3e7..469ab38b0f 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md +++ b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md @@ -17,7 +17,7 @@ The awkward part is that the question is about something that does not exist yet Interrogation is keyed by **settings namespace**, not by provider route: - `ctx.llm.registerModelDiscovery(settingsNs, discover)` lets an adapter plugin offer to interrogate endpoints for the namespace it owns, and `ctx.llm.discoverModels(settingsNs, request)` asks. There is no way to enumerate which namespaces registered: a surface that cannot interrogate learns it from the refusal, and a list nothing consumed would be a required wire field doing nothing. The namespace is the right key because a configuration surface already holds it from the configurable-provider directory, and because a provider being added has no route to name. -- `LlmModelDiscoveryRequest` carries the draft — an optional `provider`, an optional `baseURL`, an optional `api`, an optional `apiKey`, and a signal — and needs at least one of `provider` or `baseURL` to have anything to answer about. `provider` exists because a route the adapter already describes is answered from its own registry with no network call at all; only a route it does not describe reaches an endpoint. Nothing in this path writes settings or credentials. A named configured route reads its stored credential and deployment-owned profile `headers` inside the Host: the credential is write-only and the curated Models page does not edit headers, so neither can be reconstructed from that page's draft. The typed key wins over the stored credential, while the profile headers still accompany the request. +- `LlmModelDiscoveryRequest` carries the draft — an optional `provider`, an optional `baseURL`, an optional `api`, an optional `apiKey`, and a signal — and needs at least one of `provider` or `baseURL` to have anything to answer about. `provider` exists because a route the adapter already describes is answered from its own registry with no network call at all; only a route it does not describe reaches an endpoint. The custom-provider card parses a non-empty `baseURL` and permits only HTTP or HTTPS before it calls this operation or writes the profile. Nothing in this path writes settings or credentials. A named configured route reads its stored credential and deployment-owned profile `headers` inside the Host: the credential is write-only and the curated Models page does not edit headers, so neither can be reconstructed from that page's draft. The typed key wins over the stored credential, while the profile headers still accompany the request. - `LlmDiscoveredModel` makes every field but `id` optional, because most listings disclose an id and nothing else. The reply is candidates, not a catalog: a surface adopting one still owes the capacities the adapter requires. - `llm.discoverModels` carries the same draft over the wire. Its `apiKey` is the third and last payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`, and it is never stored or echoed back. It does ride the client's outgoing envelope like every other secret-bearing payload, where a `subscribeEnvelopes()` observer can see it; redacting that tap is a configuration-plane-wide change, not this method's to make alone. Connection authenticates the method with the complete Host API: it makes the host issue a GET to a caller-chosen URL and reports the outcome, which an anonymous caller must not receive. Every refusal folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. @@ -41,10 +41,10 @@ pi-ai supplies `createProvider({ fetchModels })` plus `Models.refresh()` and a ` ## Consequences -A person adding a gateway can ask it what it serves instead of hunting through its documentation, and the answer arrives as candidates they choose from rather than as configuration written behind their back. When an endpoint discloses richer metadata, adopting a candidate fills its id, name, context window, and output-token cap into the editable Web row. Search preserves hidden selections, selecting all adds the visible results, and deselecting all clears every result so a filtered picker cannot submit hidden models accidentally. An already-configured enterprise gateway uses the same deployment headers and Harness `User-Agent` for interrogation and model requests without adding a header injection field to the browser protocol. The seam gained a registry that is deliberately small: one offer per namespace, no storage, no lifecycle beyond the fiber. +A person adding a gateway can ask it what it serves instead of hunting through its documentation, and the answer arrives as candidates they choose from rather than as configuration written behind their back. A missing scheme, unsupported scheme, or malformed URL fails on the field without a Remote call; localhost, IPv4 and IPv6 literals, and custom ports remain valid. Provider and network failures remain distinct interrogation results. When an endpoint discloses richer metadata, adopting a candidate fills its id, name, context window, and output-token cap into the editable Web row. Search preserves hidden selections, selecting all adds the visible results, and deselecting all clears every result so a filtered picker cannot submit hidden models accidentally. An already-configured enterprise gateway uses the same deployment headers and Harness `User-Agent` for interrogation and model requests without adding a header injection field to the browser protocol. The seam gained a registry that is deliberately small: one offer per namespace, no storage, no lifecycle beyond the fiber. What it costs: the wire gained a third secret-carrying payload, so the configuration plane's write-only surface is now three methods rather than two. Discovery coverage remains protocol-shaped rather than provider-shaped, and an endpoint using an unsupported request contract must be filled in by hand. Because nothing re-runs the question, a model list is still only as current as its last edit; that is the same trade the layer below made deliberately. ## Testing -`packages/llm/llm/tests/topology.spec.ts` covers the registry: one offer per namespace, disposal with the fiber, normalization that drops duplicate and unusable ids without inventing capacities, the `NO_DISCOVERY`/`INVALID_DISCOVERY` refusals, and the `model-discovery-failed` Remote mapping. `packages/llm/llm-pi-ai/tests/discovery.spec.ts` drives the probe against local HTTP servers — standard arrays and enriched objects with every accepted metadata spelling, Anthropic's native path, headers, and capacity fields, route keys that differ from nested canonical ids, name fallback, a preserved deployment path, an absent credential, a configured route supplying its stored credential and headers while a typed key wins without resolving the stored one, a catalog route answering without resolving one at all, dropped rows, 401/403 versus a server fault, a non-listing and a non-JSON body, an unreachable endpoint, caller cancellation, an unsupported protocol, and the size ceiling in both its declared-length and streamed forms. `packages/llm/llm-pi-ai/tests/loader-composition.spec.ts` boots settings and credentials through the Loader and proves settings-only headers reach `GET /models` with request-owned headers winning collisions. `packages/llm/llm-pi-ai/tests/adapter.spec.ts` rejects profile headers Fetch cannot represent, and `packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts` proves a settings write reports that configuration error while its last good routes keep serving. `packages/client/connection/tests/node-half.host.spec.ts` pins the `llm/discoverModels` `/api` carrier registration, while the component and built-Web settings tests verify that the complete draft reaches the Remote, absent fields stay absent, selected metadata fills all four editable model fields, tuned rows win over rediscovery, filtered deselection clears hidden candidates, and no settings namespace or credential is written before selection. +`packages/llm/llm/tests/topology.spec.ts` covers the registry: one offer per namespace, disposal with the fiber, normalization that drops duplicate and unusable ids without inventing capacities, the `NO_DISCOVERY`/`INVALID_DISCOVERY` refusals, and the `model-discovery-failed` Remote mapping. `packages/llm/llm-pi-ai/tests/discovery.spec.ts` drives the probe against local HTTP servers — standard arrays and enriched objects with every accepted metadata spelling, Anthropic's native path, headers, and capacity fields, route keys that differ from nested canonical ids, name fallback, a preserved deployment path, an absent credential, a configured route supplying its stored credential and headers while a typed key wins without resolving the stored one, a catalog route answering without resolving one at all, dropped rows, 401/403 versus a server fault, a non-listing and a non-JSON body, an unreachable endpoint, caller cancellation, an unsupported protocol, and the size ceiling in both its declared-length and streamed forms. `packages/llm/llm-pi-ai/tests/loader-composition.spec.ts` boots settings and credentials through the Loader and proves settings-only headers reach `GET /models` with request-owned headers winning collisions. `packages/llm/llm-pi-ai/tests/adapter.spec.ts` rejects profile headers Fetch cannot represent, and `packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts` proves a settings write reports that configuration error while its last good routes keep serving. `packages/client/connection/tests/node-half.host.spec.ts` pins the `llm/discoverModels` `/api` carrier registration, while the component and built-Web settings tests verify that the complete draft reaches the Remote, malformed and non-HTTP(S) custom-provider URLs make no request, localhost and IP literals with custom ports remain valid, provider failures stay distinct from URL errors, absent fields stay absent, selected metadata fills all four editable model fields, tuned rows win over rediscovery, filtered deselection clears hidden candidates, and no settings namespace or credential is written before selection. diff --git a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md index c7612513b9..74c6bc7ed7 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md @@ -17,7 +17,7 @@ Status: implemented 询问以 **settings namespace** 为键,而不是提供方路由: - `ctx.llm.registerModelDiscovery(settingsNs, discover)` 让适配器插件为自己拥有的 namespace 提供「询问端点」的能力,`ctx.llm.discoverModels(settingsNs, request)` 发起询问。没有任何办法枚举哪些 namespace 注册过:询问不了的界面会从那句拒绝里知道,而一份无人消费的列表只会变成一个什么都不做的必填协议字段。以 namespace 为键是对的,因为配置界面已经从可配置提供方目录里拿到了它,也因为正在新增的提供方没有路由可点名。 -- `LlmModelDiscoveryRequest` 携带草稿——可选的 `provider`、可选的 `baseURL`、可选的 `api`、可选的 `apiKey`,以及一个 signal——且 `provider` 与 `baseURL` 至少要有一个,才有东西可答。`provider` 之所以存在,是因为适配器已经描述过的路由直接由它自己的注册表作答、完全不联网;只有它未描述的路由才会抵达某个端点。这条路径不写 settings 与 credentials。已配置且具名的路由会在 Host 内读取已存凭据和部署方持有的 profile `headers`:凭据只写,而精选的 Models 页面不编辑 headers,因此页面草稿无法重建两者。键入的密钥优先于已存凭据,profile headers 则仍随请求发送。 +- `LlmModelDiscoveryRequest` 携带草稿——可选的 `provider`、可选的 `baseURL`、可选的 `api`、可选的 `apiKey`,以及一个 signal——且 `provider` 与 `baseURL` 至少要有一个,才有东西可答。`provider` 之所以存在,是因为适配器已经描述过的路由直接由它自己的注册表作答、完全不联网;只有它未描述的路由才会抵达某个端点。自定义提供方卡片会解析非空的 `baseURL`,且只允许 HTTP 或 HTTPS,之后才会调用该操作或写入 profile。这条路径不写 settings 与 credentials。已配置且具名的路由会在 Host 内读取已存凭据和部署方持有的 profile `headers`:凭据只写,而精选的 Models 页面不编辑 headers,因此页面草稿无法重建两者。键入的密钥优先于已存凭据,profile headers 则仍随请求发送。 - `LlmDiscoveredModel` 除 `id` 外每个字段都可选,因为大多数列表只公布 id。回复是候选而非 catalog:采纳其中一条的界面仍要补上适配器所需的容量。 - `llm.discoverModels` 把同一份草稿送过协议层。它的 `apiKey` 是可承载机密的第三个、也是最后一个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。它确实会像其他承载机密的载荷一样随客户端外发信封同行,`subscribeEnvelopes()` 观察者看得到;把那个抽头脱敏是整个配置面的改动,不该由这一个方法独自决定。Connection 用与完整 Host API 相同的会话认证该方法:它让宿主向调用方选定的 URL 发起 GET 并回报结果,匿名调用者绝不能获得这类探测能力。每一种拒绝都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。 @@ -41,10 +41,10 @@ pi-ai 提供了 `createProvider({ fetchModels })` 加上 `Models.refresh()` 与 ## Consequences -接入网关的人可以直接问它服务什么,而不必去翻它的文档;答案以候选形式抵达,由用户自己挑选,而不是被背着写进配置。当端点公布了更丰富的元数据时,采纳候选会把 id、名称、上下文窗口与最大输出 token 数填进 Web 的可编辑行。搜索会保留隐藏项的勾选状态,全选会加入可见结果,而取消全选会清空全部结果,因此筛选后的选择器不会意外提交隐藏模型。已配置的企业网关会为询问与模型请求使用同一组部署 headers 和 Harness `User-Agent`,而无需给浏览器协议增加 header 注入字段。seam 因此多了一个刻意保持很小的注册表:每个 namespace 一份、不存储、生命周期不超出 fiber。 +接入网关的人可以直接问它服务什么,而不必去翻它的文档;答案以候选形式抵达,由用户自己挑选,而不是被背着写进配置。缺少 scheme、不支持的 scheme 或 URL 格式错误都会直接在字段处失败,不会发起 Remote 调用;localhost、IPv4 与 IPv6 字面地址以及自定义端口仍然有效。提供方与网络失败仍是独立的询问结果。当端点公布了更丰富的元数据时,采纳候选会把 id、名称、上下文窗口与最大输出 token 数填进 Web 的可编辑行。搜索会保留隐藏项的勾选状态,全选会加入可见结果,而取消全选会清空全部结果,因此筛选后的选择器不会意外提交隐藏模型。已配置的企业网关会为询问与模型请求使用同一组部署 headers 和 Harness `User-Agent`,而无需给浏览器协议增加 header 注入字段。seam 因此多了一个刻意保持很小的注册表:每个 namespace 一份、不存储、生命周期不超出 fiber。 代价是:协议层多了第三个承载机密的载荷,配置面的只写接口从两个方法变成三个。发现覆盖范围仍按协议而非按提供方划分,使用不受支持请求约定的端点仍须手工填写。由于没有任何环节会重跑该询问,模型列表的新鲜度依旧只到最近一次编辑为止;这与下层刻意做出的取舍是同一个。 ## Testing -`packages/llm/llm/tests/topology.spec.ts` 覆盖注册表:每个 namespace 一份、随 fiber dispose(资源释放)、丢弃重复与不可用 id 且不凭空补容量的归一化、`NO_DISCOVERY`/`INVALID_DISCOVERY` 两种拒绝,以及 `model-discovery-failed` Remote 映射。`packages/llm/llm-pi-ai/tests/discovery.spec.ts` 针对本地 HTTP 服务器驱动探测——包括采用每种受支持元数据拼写的标准数组与富信息对象、Anthropic 原生路径、headers 与容量字段、不同于嵌套规范 id 的路由键、名称回退、被保留的部署路径、无凭据、已配置路由提供自己的已存凭据与 headers 且键入的密钥无需解析已存凭据便可压过它、catalog 路由完全不解析凭据即作答、被丢弃的行、401/403 与服务器故障之别、非列表与非 JSON 响应、不可达端点、调用方取消、不支持的协议,以及尺寸上限的「声明长度」与「流式」两种形态。`packages/llm/llm-pi-ai/tests/loader-composition.spec.ts` 通过 Loader 启动 settings 与 credentials,并证明仅配置在 settings 中的 headers 会抵达 `GET /models`,且请求所持有的 headers 赢得冲突。`packages/llm/llm-pi-ai/tests/adapter.spec.ts` 拒绝 Fetch 无法表示的 profile headers,`packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts` 证明 settings 写入会报告该配置错误,同时上一组可用路由仍继续服务。`packages/client/connection/tests/node-half.host.spec.ts` 固定 `llm/discoverModels` 的 `/api` 承载注册,而设置页的组件测试和构建后 Web 测试则验证完整草稿抵达 Remote、缺席字段保持缺席、所选元数据填满四个可编辑模型字段、用户调整过的行优先于重新发现结果、筛选后的取消选择会清除隐藏候选项,以及选择前没有 settings namespace 或凭据被写入。 +`packages/llm/llm/tests/topology.spec.ts` 覆盖注册表:每个 namespace 一份、随 fiber dispose(资源释放)、丢弃重复与不可用 id 且不凭空补容量的归一化、`NO_DISCOVERY`/`INVALID_DISCOVERY` 两种拒绝,以及 `model-discovery-failed` Remote 映射。`packages/llm/llm-pi-ai/tests/discovery.spec.ts` 针对本地 HTTP 服务器驱动探测——包括采用每种受支持元数据拼写的标准数组与富信息对象、Anthropic 原生路径、headers 与容量字段、不同于嵌套规范 id 的路由键、名称回退、被保留的部署路径、无凭据、已配置路由提供自己的已存凭据与 headers 且键入的密钥无需解析已存凭据便可压过它、catalog 路由完全不解析凭据即作答、被丢弃的行、401/403 与服务器故障之别、非列表与非 JSON 响应、不可达端点、调用方取消、不支持的协议,以及尺寸上限的「声明长度」与「流式」两种形态。`packages/llm/llm-pi-ai/tests/loader-composition.spec.ts` 通过 Loader 启动 settings 与 credentials,并证明仅配置在 settings 中的 headers 会抵达 `GET /models`,且请求所持有的 headers 赢得冲突。`packages/llm/llm-pi-ai/tests/adapter.spec.ts` 拒绝 Fetch 无法表示的 profile headers,`packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts` 证明 settings 写入会报告该配置错误,同时上一组可用路由仍继续服务。`packages/client/connection/tests/node-half.host.spec.ts` 固定 `llm/discoverModels` 的 `/api` 承载注册,而设置页的组件测试和构建后 Web 测试则验证完整草稿抵达 Remote、格式错误和非 HTTP(S) 的自定义提供方 URL 不会发起请求、带自定义端口的 localhost 与 IP 字面地址仍然有效、提供方失败与 URL 错误保持区别、缺席字段保持缺席、所选元数据填满四个可编辑模型字段、用户调整过的行优先于重新发现结果、筛选后的取消选择会清除隐藏候选项,以及选择前没有 settings namespace 或凭据被写入。 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..94db853913 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: 513b126d3b00841f71893715cc296cb67619543e +2026-08-10-session-log-version-mechanism.zh.md: 919bfb077c7037da2ebf7eb81b75745d6b4fe4d0 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..513b126d3b 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,13 @@ 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). + +### Writer and publication authority + +`SESSION_FORMAT_VERSION` owns the checkout writer number; the [release-status reference](../../../../docs/session-format-status.md) owns one bilingual `latestReleasedVersion` and `evidenceTag` record. Publication changes independently of source development, so status is derived by comparing those facts rather than maintaining a second `released` boolean. General documentation links to these authorities; fixed-version contracts and historical evidence keep their explicit numbers. + +The [documentation-standard check](../../../../scripts/doc-standard.spec.ts) validates record structure, bilingual equality, evidence-link consistency, and the local release/writer ordering without network access. It proves internal consistency, not publication or freshness. The release operator verifies publication and updates the record after a higher format ships, as required by the [release process](../process/2026-08-10-npm-release-sequences.md). This keeps compatibility review independent of credentials and GitHub availability while making the manual freshness obligation explicit. ## Consequences @@ -28,3 +34,5 @@ What shipped in v0 (release 0812): direction-aware refusal with the raw-log path - **Default-ignorable unknown events** — inverts the failure mode of a forgotten marker from visible over-refusal into silent corruption. - **Migrating during header-only listing** — makes cheap inventory mutate storage and requires event bodies to compute facts that a header cannot prove. Listing returns descriptors; event-body reads own publication. - **Per-plugin runtime registration of known event types** — rejected because it would make the known set composition-dependent and register event names without classifying whether omission is safe. The persisted `ignorable` marker keeps that classification with each record; the [external-plugin retention decision](2026-08-30-retain-ignorable-external-session-events.md) owns the current consumer constraint. +- **Duplicate release flags or runtime status services** — introduce another mutable authority for a maintainer fact that does not control Session execution. The writer constant and publication record suffice. +- **Network-dependent documentation gates or publication automation** — network queries would couple local documentation checks to credentials and GitHub availability; a runtime service or publication workflow change is unnecessary for record consistency. Publication verification remains an explicit release-operator obligation. 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..919bfb077c 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,13 @@ 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` 是现存例子)。 + +### 写入器与发布真源 + +`SESSION_FORMAT_VERSION` 拥有工作区写入器版本号;[发布状态参考](../../../../docs/session-format-status.zh.md)拥有唯一的双语 `latestReleasedVersion` 与 `evidenceTag` 记录。发布状态独立于源码开发而变化,因此通过比较这两个事实推导状态,而不另行维护 `released` 布尔值。一般文档链接到这些真源;固定版本约定与历史证据保留明确版本号。 + +[文档标准检查](../../../../scripts/doc-standard.spec.ts)在不访问网络的情况下,校验记录结构、双语一致性、证据链接一致性及本地发布版本与写入器版本的大小关系。它证明内部一致性,而非发布事实或记录新鲜度。[发布流程](../process/2026-08-10-npm-release-sequences.zh.md)要求发布操作者在更高格式交付后核实发布并更新记录。这让兼容性评审不依赖凭据与 GitHub 可用性,同时明确人工维护新鲜度的义务。 ## 影响 @@ -28,3 +34,5 @@ v0(0812 发布)交付的内容:分方向的拒绝并带原始日志路径 - **未知事件默认可忽略**:把忘写标记的后果从可见的过度拒绝反转成静默损坏。 - **在仅 header 列表期间迁移**:让便宜清单改变存储,而且需要读取事件正文才能计算 header 无法证明的事实。列表返回 descriptor,事件正文读取负责发布。 - **插件运行时注册已知事件类型**:不予采用,因为该方案会让已知集依赖插件组合,而且只注册事件名称,无法判定省略事件是否安全。持久化的 `ignorable` 标记把该分类保留在每条记录中;[外部插件保留决策](2026-08-30-retain-ignorable-external-session-events.zh.md)定义当前消费方约束。 +- **重复发布标记或运行时状态服务**:为不控制 Session 执行的维护信息增加另一个可变真源。写入器常量与发布记录已经足够。 +- **依赖网络的文档门禁或发布自动化**:网络查询会把本地文档检查耦合到凭据与 GitHub 可用性;记录一致性不需要运行时服务或发布工作流变更。核实发布仍是发布操作者的明确义务。 diff --git a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.i18n.yaml index 3609897421..165a5ca2d0 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md -2026-08-18-experimental-agent-teams-packages.md: bff91f9c735f541eb6ef055a5bf70c31b5d88912 -2026-08-18-experimental-agent-teams-packages.zh.md: f3753a3be9679962c30b566a5a411ca568e1c982 +2026-08-18-experimental-agent-teams-packages.md: 8ccbd690882cac0a4dc844d253656681e600fb74 +2026-08-18-experimental-agent-teams-packages.zh.md: dd79d8f2171b545977b7b7e776463da0087724bc diff --git a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md index bff91f9c73..8ccbd69088 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md +++ b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md @@ -1,4 +1,4 @@ -# Agent Note: Incubate Agent Teams as private experimental packages +# Agent Note: Publish Agent Teams under experimental package names Status: implemented @@ -6,34 +6,36 @@ English | [中文](2026-08-18-experimental-agent-teams-packages.zh.md) ## Problem -Agent Teams needs the real Session log, subagent lifecycle, tools, examples, snapshots, and repository checks while its service and tool contracts continue to change. Placing those packages in a product-role group makes them members of the dsh release family and gives them the same publication expectation as stable packages. +Agent Teams needs the real Session log, subagent lifecycle, tools, examples, snapshots, and repository checks while its service and tool contracts continue to change. Users also need to install the complete Team composition from npm without building a source checkout. -An experimental directory without a current package previously imposed placement, dependency, promotion, and release rules on no consumer. Agent Teams supplies the concrete consumer, but the directory needs mechanical release exclusion and dependency isolation rather than a documentation-only status. +Moving the packages into product-role groups would remove their experimental names and imply stable-package ownership. Publishing every package under `packages/experimental/` would instead expose unrelated internal prototypes. The release policy needs an explicit Agent Teams exception while preserving the private default. ## Decision -`packages/experimental/agent-team`, `packages/experimental/tool-agent-team`, `packages/experimental/agent-team-profile`, `packages/experimental/client-ui-agent-team`, and `packages/experimental/agent-team-web-profile` are private workspace packages. The [experimental package rules](../../../../packages/experimental/AGENTS.md) own their npm names and promotion rename; this note owns their placement, release exclusion, and dependency isolation. +`packages/experimental/agent-team`, `packages/experimental/tool-agent-team`, `packages/experimental/agent-team-profile`, `packages/experimental/client-ui-agent-team`, and `packages/experimental/agent-team-web-profile` are public workspace packages. They retain their existing `@deepseek-ai/dsh-experimental-*` names and join the dsh release family. The [experimental package rules](../../../../packages/experimental/AGENTS.md) own the private default, this exception, and later promotion. -The dsh pack and publish set and the local baseline publisher exclude every manifest below `packages/experimental/`. `release:dsh` still advances their manifest versions with the shared dsh version without creating release tags. Workspace constraints require each experimental package to set `private: true` and omit `publishConfig`. The same top-level check rejects `dependencies`, `optionalDependencies`, and `peerDependencies` from release packages, release apps, or the Python runtime to an experimental package. Experimental packages may depend on release packages and each other; tests may use them through `devDependencies`, and examples may load them explicitly. +The dsh pack and publish set and the local baseline publisher include exactly these five experimental package directories. Workspace constraints require them to omit `private`, set `publishConfig.access` to `public`, and keep the experimental npm prefix. Every other experimental package remains private and excluded from publication by default. Release packages and apps outside the experimental group, plus the Python runtime, cannot name experimental packages in `dependencies`, `optionalDependencies`, or `peerDependencies`; experimental packages may depend on release packages and each other. The generic caller-reserved continuable child identity and selective direct-child drain remain in the stable Subagent service. They own Subagent identity and Activation lifecycle without importing or naming Agent Teams; the experimental Team service consumes them in the permitted direction. -The private Host-side Agent Teams profile bundle depends on the Team packages and applies after `dsh-base`. It inserts the Team rows and disables the global continuable-child controls whose model-visible names overlap the Team tools. The separate private Web profile applies after `dsh-web-app` and the Host profile; it inserts the Team UI, which mounts the Remote contribution generated by the Team package. Both layers leave the shipped base, CLI, Web, and Python runtime dependency graphs unchanged. +The published Host-side Agent Teams profile bundle depends on the Team packages and applies after `dsh-base`. It inserts the Team rows and disables the global continuable-child controls whose model-visible names overlap the Team tools. The separate published Web profile applies after `dsh-web-app` and the Host profile; it inserts the Team UI, which mounts the Remote contribution generated by the Team package. Both layers remain opt-in and leave the shipped base, CLI, Web, and Python runtime dependency graphs unchanged. -Profile startup resolves selected bundles before healing module fallbacks. The shared fallback retains the dsh installation's carrier-specific entries: symlinks under plain Node and ESM proxies in a packaged executable. Missing packages from selected bundle closures are linked under the current profile's own `node_modules`, while pnpm-managed profile entries remain authoritative. Closure discovery starts from each explicit external bundle's real package directory and traverses every listed root even when an earlier dependency has the same package name. It excludes dsh-owned profile projections from later discovery, so a projected dependency cannot feed back into its own closure. Link ownership compares canonical parent paths so junction-normalized targets remain removable. A private profile layer can therefore carry experimental plugin rows without adding those plugins to a release app, requiring profile users to install transitive packages directly, weakening packaged-runtime module identity, or changing another profile's resolution. +Profile installation resolves each published bundle and its dependencies through the profile's package manager. The generic profile launcher then applies the selected layers without adding them to any shipped profile or changing another profile's resolution. -Experimental status changes publication and compatibility expectations only. The packages retain the repository's ordinary documentation, invariant, lifecycle, security, unit, real-composition, and snapshot requirements. Promotion requires review of the public contracts, limitations, test evidence, release payload, runtime dependents, and a named owner accepting stable-package obligations. +Experimental status changes compatibility and support expectations, not publication for these five packages. They retain the repository's ordinary documentation, invariant, lifecycle, security, unit, real-composition, and snapshot requirements. Promotion still requires review of the public contracts, limitations, test evidence, runtime dependents, and a named owner accepting stable-package obligations. ## Alternatives considered -**Keep Agent Teams in a product-role group and describe it as opt-in.** Opt-in composition controls model behavior but does not exclude packages from publication or prevent stable packages from taking runtime dependencies on them. +**Move Agent Teams into product-role groups.** This would remove the requested experimental npm names and imply stable-package ownership before the contracts have stabilized. -**Reserve an empty experimental group.** A directory without a current package has no owner or release mechanism to test. The group exists only while concrete packages need its enforced treatment. +**Keep Agent Teams private and source-checkout only.** This preserves the simplest experimental policy but prevents users from installing the complete opt-in composition from npm. + +**Publish every experimental package.** Unrelated prototypes remain internal-only and have not accepted a public package contract. **Move the Subagent prerequisites into the experimental directory.** Child identity allocation and Activation teardown belong to the Subagent owner and contain no Team-specific contract. Moving or duplicating them would invert the dependency or split one lifecycle across packages. ## Consequences -Agent Teams can use the full repository graph and quality checks without entering official tarballs or becoming a supported runtime dependency. A release package cannot expose Team until the Team packages are promoted, so the CLI experiment installs an explicit private profile layer instead of changing shipped bundles. The generic profile launcher accepts that layer without making its plugin dependencies part of the dsh release closure. +Agent Teams publishes as five installable tarballs in the dsh release family without changing package names or enabling Team in a shipped profile. Public availability does not make the packages stable or supported by default, and stable release packages cannot take runtime dependencies on them. -The product-role grouping is less direct while the packages incubate. Promotion creates path and npm-name churn as specified by the experimental package rules. +The release family carries explicitly named experimental exceptions. Promotion still creates path and npm-name churn as specified by the experimental package rules. diff --git a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md index f3753a3be9..dd79d8f217 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 将 Agent Teams 作为私有实验性包孵化 +# Agent Note: 以实验性包名发布 Agent Teams Status: implemented @@ -6,34 +6,36 @@ Status: implemented ## 问题 -Agent Teams 的服务与工具约定仍在变化,但它需要使用真实 Session 日志、subagent 生命周期、工具、示例、快照和仓库检查。把这些包放在产品职责组会使其成为 dsh 发布系列成员,并获得与稳定包相同的发布预期。 +Agent Teams 的服务与工具约定仍在变化,但它需要使用真实 Session 日志、subagent 生命周期、工具、示例、快照和仓库检查。用户还需要直接从 npm 安装完整 Team 组合,而无需构建源码 checkout。 -没有实际包的 experimental 目录曾经让没有消费方的放置、依赖、promotion 和发布规则长期存在。Agent Teams 提供了具体消费方,但该目录需要机械强制的发布排除与依赖隔离,不能只用文档标记状态。 +把这些包移入产品职责组会移除实验性名称,并暗示稳定包 owner 已经就位。发布 `packages/experimental/` 下的所有包又会暴露无关的内部原型。发布策略需要为 Agent Teams 设置显式例外,同时保留默认私有原则。 ## 决策 -`packages/experimental/agent-team`、`packages/experimental/tool-agent-team`、`packages/experimental/agent-team-profile`、`packages/experimental/client-ui-agent-team` 与 `packages/experimental/agent-team-web-profile` 是私有 workspace 包。[实验性包规则](../../../../packages/experimental/AGENTS.md)负责其 npm 名和 promotion 重命名;本记录负责其目录归属、发布排除与依赖隔离。 +`packages/experimental/agent-team`、`packages/experimental/tool-agent-team`、`packages/experimental/agent-team-profile`、`packages/experimental/client-ui-agent-team` 与 `packages/experimental/agent-team-web-profile` 是公开 workspace 包。它们保留现有 `@deepseek-ai/dsh-experimental-*` 名称并加入 dsh 发布系列。[实验性包规则](../../../../packages/experimental/AGENTS.md)负责默认私有原则、本例外与后续 promotion。 -dsh pack 与 publish 集合以及本地 baseline 发布器均排除 `packages/experimental/` 下的所有 manifest。`release:dsh` 仍会让这些 manifest 跟随 dsh 共享版本递增,但不会创建发布 tag。workspace 约束要求每个实验性包设置 `private: true` 并省略 `publishConfig`。同一个顶层检查会拒绝发布包、发布 app 或 Python runtime 通过 `dependencies`、`optionalDependencies` 或 `peerDependencies` 依赖实验性包。实验性包可以依赖发布包和其他实验性包;测试可以通过 `devDependencies` 使用它们,示例可以显式加载它们。 +dsh pack 与 publish 集合以及本地 baseline 发布器只会纳入这五个实验性包目录。workspace 约束要求它们省略 `private`、设置 `publishConfig.access` 为 `public`,并保留实验性 npm 前缀。其他实验性包默认仍为私有且不发布。实验组外的发布包与 app 以及 Python runtime 不得通过 `dependencies`、`optionalDependencies` 或 `peerDependencies` 引用实验性包;实验性包可以依赖发布包和其他实验性包。 通用的调用方预留 continuable child 身份和精确 direct-child drain 仍属于稳定 Subagent 服务。它们负责 Subagent 身份与 Activation 生命周期,不 import 或命名 Agent Teams;实验性 Team 服务沿允许的方向消费这些能力。 -私有 Host 侧 Agent Teams profile bundle 依赖 Team 包,并在 `dsh-base` 之后应用。它会插入 Team 配置行,并禁用模型可见名称与 Team 工具重叠的全局 continuable-child control。独立的私有 Web profile 在 `dsh-web-app` 与 Host profile 之后应用;它会插入 Team UI,后者挂载 Team package 生成的 Remote contribution。两个层都保持已发布 base、CLI、Web 与 Python runtime 的依赖图不变。 +公开发布的 Host 侧 Agent Teams profile bundle 依赖 Team 包,并在 `dsh-base` 之后应用。它会插入 Team 配置行,并禁用模型可见名称与 Team 工具重叠的全局 continuable-child control。独立公开发布的 Web profile 在 `dsh-web-app` 与 Host profile 之后应用;它会插入 Team UI,后者挂载 Team package 生成的 Remote contribution。两个层都保持显式启用,不改变随附 base、CLI、Web 与 Python runtime 的依赖图。 -profile 启动会先解析所选 bundle,再修复模块 fallback。共享 fallback 保留 dsh 安装的载体专用条目:普通 Node 下使用 symlink,打包 executable 中使用 ESM proxy。仅由所选 bundle 闭包携带的缺失包会链接到当前 profile 自己的 `node_modules` 下,而 pnpm 管理的 profile 条目仍具有优先权。闭包发现从每个显式外部 bundle 的真实包目录开始;即使前一个依赖具有相同包名,也会遍历所有列出的根。后续发现会排除 dsh 所有的 profile projection,避免投影后的依赖重新进入自己的闭包。link ownership 通过规范化父路径比较,使 junction 规范化后的 target 仍可删除。因此,私有 profile 层可以携带实验性 plugin 配置行,而无需把这些 plugin 加入发布 app、要求 profile 用户直接安装传递依赖、破坏 packaged-runtime 的模块身份,或改变其他 profile 的解析结果。 +profile 安装通过自身 package manager 解析每个公开 bundle 及其依赖。通用 profile launcher 随后应用所选层,不会把它们加入任何随附 profile,也不会改变其他 profile 的解析结果。 -实验性状态只改变发布与兼容性预期。这些包仍须满足仓库的一般文档、不变式、生命周期、安全、单元测试、真实组合测试和快照要求。promotion 前必须评审公开约定、限制、测试证据、发布 payload、运行时依赖方,并由一名具名 owner 接受稳定包义务。 +对这五个包而言,实验性状态改变兼容性与支持预期,而不阻止发布。这些包仍须满足仓库的一般文档、不变式、生命周期、安全、单元测试、真实组合测试和快照要求。promotion 前仍须评审公开约定、限制、测试证据、运行时依赖方,并由一名具名 owner 接受稳定包义务。 ## 曾考虑的替代方案 -**把 Agent Teams 留在产品职责组,并标为显式启用。** 显式启用的组合可以控制模型行为,但不会阻止包发布,也不能阻止稳定包对其建立运行时依赖。 +**把 Agent Teams 移入产品职责组。** 这会移除要求保留的实验性 npm 名称,并在约定稳定前暗示已有稳定包 owner。 -**预留空的 experimental 组。** 没有实际包的目录没有 owner,也没有可供测试的发布机制。只有具体包需要这套强制处理时,该组才存在。 +**让 Agent Teams 保持私有且仅供源码 checkout 使用。** 这会保留最简单的实验性策略,但用户无法从 npm 安装完整 opt-in 组合。 + +**发布所有实验性包。** 其他原型仍只供内部使用,也没有接受公开包约定。 **把 Subagent 前置能力移入 experimental 目录。** child 身份分配与 Activation teardown 属于 Subagent owner,且不包含 Team 专用约定。移动或复制这些能力会反转依赖方向,或把同一个生命周期拆到多个包中。 ## 后果 -Agent Teams 可以使用完整仓库依赖图与质量检查,而不进入正式 tarball,也不会成为受支持的运行时依赖。在 Team 包 promotion 前,发布包不能暴露 Team,因此 CLI 实验会安装显式的私有 profile 层,而不是修改已发布 bundle。通用 profile launcher 可以接受该层,而不会让它的 plugin 依赖进入 dsh 发布闭包。 +Agent Teams 会作为 dsh 发布系列中的五个可安装 tarball 发布,同时保持包名不变,也不会在随附 profile 中启用 Team。公开可用不代表这些包稳定或默认受支持,稳定发布包也不能对其建立运行时依赖。 -孵化期间的产品职责分组不够直接。promotion 会按照实验性包规则产生路径和 npm 名改动。 +发布系列需要维护显式命名的实验性例外。promotion 仍会按照实验性包规则产生路径和 npm 名改动。 diff --git a/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.i18n.yaml index 49ee6b91b8..1aa080d1fe 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md -2026-08-23-locale-owned-client-ui-copy.md: 623189b8faa45f0ef2a1b724d3e050d33f7d59bd -2026-08-23-locale-owned-client-ui-copy.zh.md: e2a6f49de89e6e6ba2734dd69cf536a3e13d2aea +2026-08-23-locale-owned-client-ui-copy.md: bc4d8efbce1f246f712e3afe6af40d1518e8cb17 +2026-08-23-locale-owned-client-ui-copy.zh.md: ce996a4aafc392e739bec2eccd8714cfeff7d8e9 diff --git a/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md b/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md index 623189b8fa..bc4d8efbce 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md +++ b/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md @@ -12,6 +12,8 @@ Typed locale namespaces and bilingual dictionary parity proved that registered d **Locale dictionaries own all product-authored client UI wording.** Visible text, accessibility names, tooltips, placeholders, empty states, status labels, units, and formatting templates reach presentation through a typed `t` seat or an already-localized prop. A value authored by a user, model, provider, plugin, wire peer, or operating system remains data and renders verbatim; protocol tags, tool names, paths, URLs, JSON/JavaScript literals, and stable internal ids are not translated. +Product-owned catalog descriptions follow the same rule. The client maps an exact built-in provider, model, and description to a locale key; a changed description or an external provider description remains provider data and renders verbatim. + **Cordis-free primitives require complete localized copy props and own no language fallback.** `MarkdownText`, `JsonTree`, `TerminalBlock`, `DiffBlock`, `ReadBlock`, `SearchBlock`, `WebBlock`, `CodeBlock`, `JsonBlock`, `HoverCard`, and `ConnectionIndicator` receive their chrome from the feature render site. This preserves the primitive package's runtime independence while making omission a type error instead of silently selecting Chinese or English. Shared words live in the `common` namespace; feature-specific phrases stay with the feature that decides their meaning. **Localized display text is never an identity.** Models and stores retain discriminants, stable ids, and non-display markers. Renderers translate after matching, and request maps carry stable group membership into the trajectory ledger. A client-synthesized error that must survive in a view model uses a stable marker and is translated only when displayed. Language switching therefore changes wording without changing selection, grouping, search identity, or lifecycle state. @@ -22,7 +24,7 @@ The product-authored error and design-literal exclusions, primitive defaults, an ## Verification -The AST check's own Vitest spec pins direct JSX, template branches, semantic copy props, label data, formatter returns, locale-key calls, structural attributes, and dictionary owners. Locale dictionary parity pins identical `zh`/`en` keys. Client component suites exercise both direct translated seats and locale-prop adapters, and the assembled web replay plus the required real-server GIF demonstrate the shipped locale switch on the actual trajectory surface. +The AST check's own Vitest spec pins direct JSX, template branches, semantic copy props, label data, formatter returns, locale-key calls, structural attributes, and dictionary owners. Locale dictionary parity pins identical `zh`/`en` keys. Client suites exercise direct translated seats, locale-prop adapters, and built-in catalog-description localization without altering external descriptions. The assembled web replay plus the required real-server GIF demonstrate the shipped locale switch on the actual trajectory surface. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md b/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md index e2a6f49de8..ce996a4aaf 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md @@ -12,6 +12,8 @@ typed locale namespace 与双语字典对等性可以证明已注册字典完整 **所有产品编写的 client UI 措辞都由 locale 字典持有。** 可见文本、无障碍名称、tooltip、placeholder、空状态、状态标签、单位和格式模板必须经 typed `t` 席位或已本地化 prop 到达展示层。由用户、模型、提供方、插件、wire 对端或操作系统编写的值仍是数据并原样渲染;协议 tag、工具名称、路径、URL、JSON/JavaScript 字面量和稳定内部 id 不翻译。 +产品持有的目录说明遵循同一规则。client 将完全匹配的内置提供方、模型与说明映射到 locale key;说明发生变化或来自外部提供方时,它仍是提供方数据并原样渲染。 + **Cordis-free 原子组件要求完整的本地化文案 prop,且自身不持有语言回落值。** `MarkdownText`、`JsonTree`、`TerminalBlock`、`DiffBlock`、`ReadBlock`、`SearchBlock`、`WebBlock`、`CodeBlock`、`JsonBlock`、`HoverCard` 与 `ConnectionIndicator` 的 chrome 均由功能渲染点传入。这样既保留原子组件包的运行时独立性,也让遗漏成为类型错误,而不是静默选择中文或英文。共享用词进入 `common` namespace;功能专属短语留在决定其语义的功能侧。 **本地化展示文本绝不承担身份。** 模型与存储保留判别字段、稳定 id 和非展示 marker。渲染器先匹配再翻译,请求映射通过稳定的组成员关系进入 trajectory ledger。必须保存在视图模型中的 client 合成错误使用稳定 marker,只在展示时翻译。因此语言切换只改变措辞,不改变选择、分组、搜索身份或生命周期状态。 @@ -22,7 +24,7 @@ typed locale namespace 与双语字典对等性可以证明已注册字典完整 ## Verification -AST 检查自身的 Vitest spec 固定直接 JSX、模板分支、语义文案 prop、label 数据、格式化函数返回值、locale key 调用、结构属性和字典 owner。locale 字典对等性固定 `zh`/`en` key 一致。client 组件测试同时覆盖直接翻译席位与 locale prop 适配器;组装 web 回放和规定的真实服务器 GIF 在实际 trajectory 界面上展示发布的语言切换。 +AST 检查自身的 Vitest spec 固定直接 JSX、模板分支、语义文案 prop、label 数据、格式化函数返回值、locale key 调用、结构属性和字典 owner。locale 字典对等性固定 `zh`/`en` key 一致。client 测试覆盖直接翻译席位、locale prop 适配器,以及不改变外部说明的内置目录说明本地化。组装 web 回放和规定的真实服务器 GIF 在实际 trajectory 界面上展示发布的语言切换。 ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.i18n.yaml index 6d86465557..0521855ea8 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-27-handle-based-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-08-27-handle-based-session-persistence.md -2026-08-27-handle-based-session-persistence.md: 9b5ff5d46444ac924859c4121abc1cf5d1538485 -2026-08-27-handle-based-session-persistence.zh.md: c9230c899141f4ab9979f93256da42a3aaae0984 +2026-08-27-handle-based-session-persistence.md: e2f07856b7ef8ffd8180ed7ff515210c95dde29b +2026-08-27-handle-based-session-persistence.zh.md: 472a1024887ecca67245de27f52c68ec0b34caa1 diff --git a/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.md b/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.md index 9b5ff5d464..e2f07856b7 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.md +++ b/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.md @@ -32,7 +32,7 @@ The previous persistence seam owned far more than storage. A shared coordinator ## Consequences -Resume, fork, subagent, ACP, webhook, and SDK sessions all persist through one explicit acquisition point, and dispose provably releases write ownership (reopening for write succeeds after teardown). The costs: a backend plugin reload under live sessions invalidates their handles — writes fail loudly until the sessions restart, where adoption previously re-attached silently; `ctx.sessions.create` + `flush` in a test persists nothing without a handle (tests seed through `create`/`append`/`close`); resume re-reads a cold log only when no immediately preceding observation parsed the same artifact — a bounded provider-local memo (session id + stat revision, invalidated by every local mutation) serves the observe-then-promote and authorize-then-resume handoffs without restoring the deleted borrow/reservation lifecycle, and the session-query reader's own prepared cache remains the pin-capable layer above it (a later consolidation may fold one into the other); and an empty created session is invisible to other processes until an explicit flush (ACP forces one for its resumable-empty-session promise). `SESSION_FORMAT_VERSION` stays 0. +Resume, fork, subagent, ACP, webhook, and SDK sessions all persist through one explicit acquisition point, and dispose provably releases write ownership (reopening for write succeeds after teardown). The costs: a backend plugin reload under live sessions invalidates their handles — writes fail loudly until the sessions restart, where adoption previously re-attached silently; `ctx.sessions.create` + `flush` in a test persists nothing without a handle (tests seed through `create`/`append`/`close`); resume re-reads a cold log only when no immediately preceding observation parsed the same artifact — a bounded provider-local memo (session id + stat revision, invalidated by every local mutation) serves the observe-then-promote and authorize-then-resume handoffs without restoring the deleted borrow/reservation lifecycle, and the session-query reader's own prepared cache remains the pin-capable layer above it (a later consolidation may fold one into the other); and an empty created session is invisible to other processes until an explicit flush (ACP forces one for its resumable-empty-session promise). Handle ownership does not change the serialized Session representation. ## Related diff --git a/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.zh.md b/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.zh.md index c9230c8991..472a102488 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.zh.md @@ -32,7 +32,7 @@ Status: implemented ## 后果 -恢复、fork、subagent、ACP、webhook 与 SDK 会话全部经由一个显式获取点持久化,且 dispose 可证明地释放写所有权(teardown 之后重新以写模式打开可以成功)。代价:在有活跃会话时重载后端插件会使它们的句柄失效——写入会响亮地失败,直到会话重启,而以前接管会静默重连;测试中 `ctx.sessions.create` + `flush` 在没有句柄时什么也不持久化(测试通过 `create`/`append`/`close` 播种);只有当紧邻其前没有观察读解析过同一产物时,恢复才重新读取冷日志——一个有界的 provider 内部 memo(按会话 id + stat 修订号,任何本地修改都使其失效)服务观察后提升与授权后恢复这两类交接,而不恢复已删除的 borrow/reservation 生命周期;session-query reader 自己的已准备缓存仍是其上方具备 pin 能力的一层(后续可考虑二者收敛);空的已创建会话在显式 flush 之前对其他进程不可见(ACP 为其可恢复空会话承诺强制执行一次 flush)。`SESSION_FORMAT_VERSION` 保持为 0。 +恢复、fork、subagent、ACP、webhook 与 SDK 会话全部经由一个显式获取点持久化,且 dispose 可证明地释放写所有权(teardown 之后重新以写模式打开可以成功)。代价:在有活跃会话时重载后端插件会使它们的句柄失效——写入会响亮地失败,直到会话重启,而以前接管会静默重连;测试中 `ctx.sessions.create` + `flush` 在没有句柄时什么也不持久化(测试通过 `create`/`append`/`close` 播种);只有当紧邻其前没有观察读解析过同一产物时,恢复才重新读取冷日志——一个有界的 provider 内部 memo(按会话 id + stat 修订号,任何本地修改都使其失效)服务观察后提升与授权后恢复这两类交接,而不恢复已删除的 borrow/reservation 生命周期;session-query reader 自己的已准备缓存仍是其上方具备 pin 能力的一层(后续可考虑二者收敛);空的已创建会话在显式 flush 之前对其他进程不可见(ACP 为其可恢复空会话承诺强制执行一次 flush)。句柄所有权不改变序列化的 Session 表示。 ## 相关 diff --git a/.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.i18n.yaml index d018fd81f7..c976a60147 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-alpha-historical-unknown-event-refusal.md -2026-08-31-alpha-historical-unknown-event-refusal.md: 58690e30281c1f5e10f85726c1f1e50fd4664fe9 -2026-08-31-alpha-historical-unknown-event-refusal.zh.md: 73ab2ca47ab3f68b11e71ffec09287253215aa53 +2026-08-31-alpha-historical-unknown-event-refusal.md: c63b36f63206e0992416239483d908b0645a98c2 +2026-08-31-alpha-historical-unknown-event-refusal.zh.md: 231c46c11486b05a69a200b857a0ea6df35b6e79 diff --git a/.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.md b/.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.md index 58690e3028..c63b36f632 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.md +++ b/.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.md @@ -14,7 +14,7 @@ Silently copying such an event can leave stale numeric references after a later The alpha v0-to-v1 edge owns a frozen complete released-v0 event and payload inventory. It refuses every unknown historical event type before target staging, including an event marked `ignorable: true`, and refuses unexpected members of known payloads except fields explicitly classified as owner-opaque JSON. Merge-extensible nested discriminants remain part of that explicit policy: unknown content-block types, message-source kinds, assistant finish-reason kinds, and turn-ending reason kinds are preserved as owner-opaque JSON, while known arms receive structural validation. The diagnostic names the event type, its sequence number, and the unchanged source generation. -The rule applies only while crossing a historical format edge. Ordinary current-format reading retains the established envelope behavior: an unknown required event refuses, while an unknown event carrying `ignorable: true` remains readable. New v1 external events therefore keep the existing equal-version extension seam, but they do not become implicitly migratable by a future format edge. +The rule applies only while crossing a historical format edge. Ordinary current-format reading retains the established envelope behavior: an unknown required event refuses, while an unknown event carrying `ignorable: true` remains readable. Native current-format external events therefore keep the existing equal-version extension seam, but they do not become implicitly migratable by a future format edge. Every first-party source event type has an executable disposition and target validator in the edge package. The catalog is build-static and profile-independent, so mounting or omitting the producer plugin cannot change whether an old artifact migrates. diff --git a/.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.zh.md b/.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.zh.md index 73ab2ca47a..231c46c114 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.zh.md @@ -14,7 +14,7 @@ Status: implemented Alpha v0-to-v1 迁移边拥有冻结且完整的已发布 v0 事件与 payload 清单。它在目标 staging 前拒绝每个未知历史事件类型,包括标记了 `ignorable: true` 的事件;除明确分类为 owner 不透明 JSON 的字段外,它也拒绝已知 payload 的意外成员。可合并扩展的嵌套判别字段同样属于这项显式策略:未知 content-block type、message-source kind、assistant finish-reason kind 与 turn-ending reason kind 会作为 owner 不透明 JSON 保留,已知分支则接受结构校验。诊断会点名事件类型、序号和保持不变的源 generation。 -该规则只适用于跨越历史格式迁移边。普通当前格式读取保留既有信封行为:未知必需事件被拒绝,带 `ignorable: true` 的未知事件仍可读取。因此新的 v1 外部事件继续使用既有同版本扩展 seam,但不会自动获得未来格式迁移能力。 +该规则只适用于跨越历史格式迁移边。普通当前格式读取保留既有信封行为:未知必需事件被拒绝,带 `ignorable: true` 的未知事件仍可读取。因此原生当前格式的外部事件继续使用既有同版本扩展 seam,但不会自动获得未来格式迁移能力。 每个第一方源事件类型都在迁移边包中拥有可执行 disposition 与目标 validator。catalog 在构建时静态确定且与 profile 无关,因此 producer 插件是否挂载不会改变旧产物能否迁移。 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..759741d3f1 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: d566459af64f4177ed0135813e75aaf77480823c +2026-08-31-released-session-format-migrations.zh.md: f82b406117691197d808111f1c8aa4c722d4bab2 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..d566459af6 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. +The [version and release-status reference](../../../../docs/session-format-status.md) owns the published-format record and identifies the code’s writer authority. Released formats retain their 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 a released conversion. Ordinary event additions follow that rule’s required-event refusal mechanism rather than automatically allocating a version. A current-format 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..f82b406117 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 和未变更的历史输入,而非改写已提交代际。 +[版本与发布状态参考](../../../../docs/session-format-status.zh.md)拥有已发布格式记录,并指明代码中的写入器真源。已发布格式保留其语义;迁移期间已提交代际的字节保持不变。后续结构性变更必须按[版本规则](2026-08-10-session-log-version-mechanism.zh.md)添加下一条相邻迁移边,而非修改已发布转换。普通事件新增遵循该规则的必需事件拒绝机制,而非自动分配版本。当前格式文件不会重新执行入边迁移;集成测试使用隔离、可丢弃的 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..174aa2592e 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: 1500fd2350f02ab5d8f203c0d62b98832c6c5de5 +2026-09-02-system-prompt-as-surface-node.zh.md: 306f347092caa3e9daf2494fa26d290b1c1ab9a7 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..1500fd2350 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) preserves each released conversion’s semantics; an existing target-format generation does not rerun its incoming edge. Projection-cache versions are independent of Session format versions. 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..306f347092 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)保留每条已发布转换的语义;已有目标格式代际不会重跑其入边。投影缓存版本独立于 Session 格式版本。 [规范信封规范](../../../../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/architecture/2026-09-05-workspace-files-service.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml index d08fb5f3f3..47eb403310 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-workspace-files-service.md -2026-09-05-workspace-files-service.md: 39b73b71517934cf3007f042ac58061f655d6b85 -2026-09-05-workspace-files-service.zh.md: e4769a44a3517dffe36003e93cdeb3b258d6b443 +2026-09-05-workspace-files-service.md: b2dbdfc99388d8c2f18991a7704599d2d95f070b +2026-09-05-workspace-files-service.zh.md: d860d220af50a24a6e0f40b640d0a8fb534c4741 diff --git a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md index 39b73b7151..b2dbdfc993 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md +++ b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md @@ -20,23 +20,23 @@ Two constraints frame the service. File reads through `ctx.fs` use the Session's | Face | Package | Files | Depends on | |---|---|---|---| -| Host | `api/workspace-files/tsconfig.host.json` | `src/index.ts` (`WorkspaceFiles`, `Config`, gates, pager), `src/changes.ts` (`WorkspaceChangeFeed`), `src/types.ts` (wire types, error codes) | `dsh-fs`, `dsh-sandbox-policy`, `dsh-typert-protocol`, `dsh-agent`, `dsh-session` | -| Client | `api/workspace-files/tsconfig.client.json` | `src/client/index.ts` (plugin body), `provider.ts`, `change-feed.ts`, `remote.ts`, `types.ts`, and shared `src/types.ts` | `dsh-api-gateway/client`, `dsh-api-session-controller/client`, `dsh-client-resources`, `dsh-util-workspace-path`, `dsh-typert-protocol`, and the package's generated `./remote` | +| Host | `api/workspace-files/tsconfig.host.json` | `src/index.ts` (`WorkspaceFiles`, `Config`, gates, pager), `src/changes.ts` (`WorkspaceChangeFeed`), `src/types.ts` (wire types, error codes) | `dsh-fs`, `dsh-sandbox-policy`, `dsh-typert-protocol`, `dsh-session`, `dsh-session-persistence` | +| Client | `api/workspace-files/tsconfig.client.json` | `src/client/index.ts` (plugin body), `provider.ts`, `change-feed.ts`, `remote.ts`, `types.ts`, and shared `src/types.ts` | `dsh-api-gateway/client`, `dsh-session/types`, `dsh-client-resources`, `dsh-client-ui-slots`, `dsh-util-workspace-path`, `dsh-typert-protocol`, and the package's generated `./remote` | `api/remotes` and both root aggregates reference the matching Host/Client leaf. The package exports `.`, `./client`, `./types`, `./typert`, and `./remote`, with one `workspace-files` web-app row supplying both faces. The Client plugin injects `['resources', 'remote', 'remote.workspaceFiles']`; the resource model takes result types directly from the protocol package, and the text preview owns the Sidebar parameter declaration, so the Client compilation graph has no reverse dependency on Remote assembly or Sidebar UI. ### The `workspaceFiles` Remote namespace -Every Host method takes the target `Agent` first, resolved by the Gateway from the Session identity on the wire, so a Client calls `remote.workspaceFiles.stat(sessionId, path, signal)` and never names a root. The seven signatures, as `src/index.ts` declares them: +Every Host method takes `WorkspaceFileScope` first. The Gateway resolves it from the wire Session identity by reading the live Session header or, for a cold Session, `SessionPersistence.stat`; it never activates an Agent, reads the event body, or falls back to a parent Session. The scope carries the selected Session id and its `cwd`, with the sandbox policy's deployment root used only when that header has no `cwd`. A Client passes its Session id and never names a root. The seven signatures, as `src/index.ts` declares them: ```ts ignore-check -@Remote async read(agent: Agent, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise -@Remote async readBytes(agent: Agent, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise -@Remote async readAll(agent: Agent, path: string, signal: AbortSignal): Promise -@Remote async readRelated(agent: Agent, path: string, relativePath: string, signal: AbortSignal): Promise -@Remote async stat(agent: Agent, path: string, signal: AbortSignal): Promise -@Remote async list(agent: Agent, path: string, signal: AbortSignal): Promise -@Remote({ mode: 'stream' }) changes(agent: Agent, signal: AbortSignal): AsyncIterable +@Remote async read(workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise +@Remote async readBytes(workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise +@Remote async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise +@Remote async readRelated(workspaceFileScope: WorkspaceFileScope, path: string, relativePath: string, signal: AbortSignal): Promise +@Remote async stat(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise +@Remote async list(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise +@Remote({ mode: 'stream' }) changes(workspaceFileScope: WorkspaceFileScope, signal: AbortSignal): AsyncIterable ``` - **`stat`** returns `WorkspaceFileStat { absolutePath, version, bytes? }`: the file's identity, its opaque freshness token, and its size when the backend reports one. It accepts a regular file only. @@ -57,7 +57,7 @@ Two path vocabularies leave the service, and each method uses exactly one. `read `read`, `readBytes`, `readAll`, `readRelated`, and `stat` share regular-file checks and then rely on the filesystem backend's read authority. `list` shares path inspection but also checks workspace containment, while `changes` filters observations to the workspace root. The service applies the following checks: 1. **The path itself.** `lstat` inspects the path before anything follows it: a missing path is `not-found`, and a symlink — wherever it points, including back inside the workspace — is `not-regular-file` (kind `symlink`) for the file methods and `not-directory` for `list`. An empty path is a `gateway/bad-request`. -2. **Workspace containment for `list`.** The directory resolves to a target and `ctx.fs.contains(root, target)` decides, where `root` is `sandboxPolicy.resolve({ session }).workspaceRoot` resolved the same way. A `..` traversal or an absolute directory outside the root is `outside-workspace`. `changes` applies the same backend containment predicate to observed targets. +2. **Workspace containment for `list`.** The directory resolves to a target and `ctx.fs.contains(root, target)` decides, where `root` is the `WorkspaceFileScope.workspaceRoot` resolved from the selected Session header. A `..` traversal or an absolute directory outside the root is `outside-workspace`. `changes` applies the same backend containment predicate to observed targets. 3. **The caps.** A page or window above `maxBytes`, or a `read` asking for more than `maxLines`, is refused, never shortened, because a silently cut page reads as the whole page; a listing above `maxEntries` is cut and says so. Complete and related-file reads are refused above `maxFileBytes`. 4. **Text.** For `read` only: content that is not UTF-8 up to the end of the page, a NUL byte in the backend's 8 KiB opening sample, or a NUL byte anywhere in the page is `not-text`; bytes past the page are not inspected. @@ -131,7 +131,7 @@ The [resource model](2026-09-05-client-resource-model.md) owns `ctx.resources`, ## Consequences -- Workspace file access belongs to the Host/Client faces of `api/workspace-files`; the Session Controller carries neither implementation, and compiler and runtime entries stay separate. +- Workspace file access belongs to the Host/Client faces of `api/workspace-files`; the Session Controller carries neither implementation, and compiler and runtime entries stay separate. Header-only Session scope lets ordinary, subagent, live, and cold Sessions resolve their own relative paths without an Agent lifecycle or parent fallback. - A file of any size opens: text by line page, anything by byte window, each costing one page or window of memory on the Host; complete reads instead enforce `maxFileBytes`; the cost is that a consumer assembles pages itself and that a single line above `maxBytes` has no page at all, because pages are cut by lines. - Every filesystem provider now offers a windowed raw read. `fs-e2b` pays for it by transferring the skipped prefix, since its SDK cannot seek; `fs-local` seeks. - Paths on the wire are canonical: `absolutePath` and change frames spell a file with symlinks resolved. A follower binds to successful `stat.absolutePath`, so another spelling of the same file — a workspace root reached through a symlink — uses that canonical change key. @@ -142,7 +142,7 @@ The [resource model](2026-09-05-client-resource-model.md) owns `ctx.resources`, ## Testing -Host specs in `packages/api/workspace-files/tests` exercise the paged read (whole file, nested path, empty file, multi-byte UTF-8, the line window's edges, defaults and refused limits, carriage returns kept), the byte window (defaults, a middle window with more following, tail windows exact and short, past-end and empty files, NUL and invalid UTF-8 round-tripping through base64, version parity with `stat`, the cap as `too-large`, bad ranges, a window of a file far above the cap, and `eof` inferred without a size), `stat`, outside-workspace reads and backend refusals, `list` with containment, truncation, symlink children, and `not-directory`, and the `changes` stream driven by `fs/observed` and filtered by root. Client specs in `packages/api/workspace-files/tests` cover the provider's frames (opening stat, failure frames, writes without content, disappearance, recovery, abort), the change feed (one stream per session, fan-out by normalized path, queued frames, ending on signal or Host close), the unsupported-address cases, and registration and disposal with the fiber. `fs/fs`, `fs-local`, and `fs-e2b` specs pin `readByteRange`'s range semantics — a middle window, a tail shorter than asked, past-end and zero-length windows, errors, aborts, and the e2b cancel — and `dsh-util-workspace-path` specs pin the file-address grammar. `readAll` and `readRelated` specs cover complete-read caps, outside base and related paths, and Host backend authorization. The connection fixture serves `stat`, paged `read`, `list`, and an opt-in `changes` frame for the web e2e suite. +Host specs in `packages/api/workspace-files/tests` exercise header-only scope resolution for live and cold subagent Sessions, the deployment fallback, missing identities, and lookup disposal; the paged read (whole file, nested path, empty file, multi-byte UTF-8, the line window's edges, defaults and refused limits, carriage returns kept); the byte window (defaults, a middle window with more following, tail windows exact and short, past-end and empty files, NUL and invalid UTF-8 round-tripping through base64, version parity with `stat`, the cap as `too-large`, bad ranges, a window of a file far above the cap, and `eof` inferred without a size); `stat`; outside-workspace reads and backend refusals; `list` with containment, truncation, symlink children, and `not-directory`; and the `changes` stream driven by `fs/observed` and filtered by root. Client specs cover the provider's frames, the change feed, unsupported addresses, and registration and disposal. `fs/fs`, `fs-local`, and `fs-e2b` specs pin `readByteRange`; `dsh-util-workspace-path` specs pin the file-address grammar. The connection fixture serves `stat`, paged `read`, `list`, and an opt-in `changes` frame for the web e2e suite. ## Deferred diff --git a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md index e4769a44a3..d860d220af 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md @@ -20,23 +20,23 @@ Web 客户端需要从一个未必在 Host 机器上的浏览器查看会话工 | 面 | 包 | 文件 | 依赖 | |---|---|---|---| -| Host | `api/workspace-files/tsconfig.host.json` | `src/index.ts`(`WorkspaceFiles`、`Config`、围栏、切页器)、`src/changes.ts`(`WorkspaceChangeFeed`)、`src/types.ts`(线路类型、错误码) | `dsh-fs`、`dsh-sandbox-policy`、`dsh-typert-protocol`、`dsh-agent`、`dsh-session` | -| Client | `api/workspace-files/tsconfig.client.json` | `src/client/index.ts`(插件体)、`provider.ts`、`change-feed.ts`、`remote.ts`、`types.ts`,以及共享的 `src/types.ts` | `dsh-api-gateway/client`、`dsh-api-session-controller/client`、`dsh-client-resources`、`dsh-util-workspace-path`、`dsh-typert-protocol`,以及本包生成的 `./remote` | +| Host | `api/workspace-files/tsconfig.host.json` | `src/index.ts`(`WorkspaceFiles`、`Config`、围栏、切页器)、`src/changes.ts`(`WorkspaceChangeFeed`)、`src/types.ts`(线路类型、错误码) | `dsh-fs`、`dsh-sandbox-policy`、`dsh-typert-protocol`、`dsh-session`、`dsh-session-persistence` | +| Client | `api/workspace-files/tsconfig.client.json` | `src/client/index.ts`(插件体)、`provider.ts`、`change-feed.ts`、`remote.ts`、`types.ts`,以及共享的 `src/types.ts` | `dsh-api-gateway/client`、`dsh-session/types`、`dsh-client-resources`、`dsh-client-ui-slots`、`dsh-util-workspace-path`、`dsh-typert-protocol`,以及本包生成的 `./remote` | `api/remotes` 和两个根聚合分别引用匹配的 Host/Client 叶子。包导出 `.`、`./client`、`./types`、`./typert` 和 `./remote`,web-app 中单个 `workspace-files` 条目供应两面。Client 插件注入 `['resources', 'remote', 'remote.workspaceFiles']`;资源模型直接从协议包取结果类型,Sidebar 参数声明归文本预览,因此 Client 编译图不再反向依赖 Remote 装配或右栏 UI。 ### `workspaceFiles` Remote 命名空间 -每个 Host 方法首参都是目标 `Agent`,由 Gateway 从线路上的 Session 身份解析而来,因此 Client 调用 `remote.workspaceFiles.stat(sessionId, path, signal)`,从不自行命名根。七个签名照 `src/index.ts` 的声明: +每个 Host 方法首参都是 `WorkspaceFileScope`。Gateway 从线路上的 Session 身份解析它:优先读取 live Session header,cold Session 则只调用 `SessionPersistence.stat`;不会激活 Agent、读取事件正文或回退到父 Session。scope 携带所选 Session id 及其 `cwd`,仅当该 header 没有 `cwd` 时才使用沙箱策略的部署根。Client 传入 Session id,从不自行命名根。七个签名照 `src/index.ts` 的声明: ```ts ignore-check -@Remote async read(agent: Agent, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise -@Remote async readBytes(agent: Agent, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise -@Remote async readAll(agent: Agent, path: string, signal: AbortSignal): Promise -@Remote async readRelated(agent: Agent, path: string, relativePath: string, signal: AbortSignal): Promise -@Remote async stat(agent: Agent, path: string, signal: AbortSignal): Promise -@Remote async list(agent: Agent, path: string, signal: AbortSignal): Promise -@Remote({ mode: 'stream' }) changes(agent: Agent, signal: AbortSignal): AsyncIterable +@Remote async read(workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise +@Remote async readBytes(workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise +@Remote async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise +@Remote async readRelated(workspaceFileScope: WorkspaceFileScope, path: string, relativePath: string, signal: AbortSignal): Promise +@Remote async stat(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise +@Remote async list(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise +@Remote({ mode: 'stream' }) changes(workspaceFileScope: WorkspaceFileScope, signal: AbortSignal): AsyncIterable ``` - **`stat`** 返回 `WorkspaceFileStat { absolutePath, version, bytes? }`:文件身份、不透明的新鲜度令牌,以及后端报得出时的大小。它只接受普通文件。 @@ -57,7 +57,7 @@ Web 客户端需要从一个未必在 Host 机器上的浏览器查看会话工 `read`、`readBytes`、`readAll`、`readRelated` 与 `stat` 共享普通文件检查,之后依赖文件系统后端的读取权限。`list` 共享路径检查,但还会检查工作区包含关系;`changes` 则把观察过滤到工作区根内。服务执行以下检查: 1. **路径本身。** `lstat` 在跟随任何东西之前检查路径:缺失路径为 `not-found`;符号链接——不论指向哪里,包括指回工作区内——对文件方法为 `not-regular-file`(kind 为 `symlink`),对 `list` 为 `not-directory`。空路径是 `gateway/bad-request`。 -2. **`list` 的工作区包含。** 目录解析为目标,由 `ctx.fs.contains(root, target)` 判定,其中 `root` 是以同样方式解析的 `sandboxPolicy.resolve({ session }).workspaceRoot`。`..` 爬出或根外绝对目录为 `outside-workspace`。`changes` 对观察到的目标使用相同的后端包含判定。 +2. **`list` 的工作区包含。** 目录解析为目标,由 `ctx.fs.contains(root, target)` 判定,其中 `root` 是从所选 Session header 解析出的 `WorkspaceFileScope.workspaceRoot`。`..` 爬出或根外绝对目录为 `outside-workspace`。`changes` 对观察到的目标使用相同的后端包含判定。 3. **上限。** 超过 `maxBytes` 的页或窗口,或 `read` 索要超过 `maxLines` 的行数,一律拒绝、绝不截短,因为悄悄截短的页读起来就像整页;超过 `maxEntries` 的列表被截断并如实报告。全文及关联文件读取超过 `maxFileBytes` 时被拒绝。 4. **文本。** 仅限 `read`:到页末为止不是 UTF-8 的内容、后端 8 KiB 开头样本里的 NUL 字节,或页内任何位置的 NUL 字节,都是 `not-text`;页之后的字节不检查。 @@ -131,7 +131,7 @@ Client 导出向 `ctx.resources` 注册一个 `ResourceProvider<'file'>`,存 ## Consequences -- 工作区文件访问由 `api/workspace-files` 的 Host/Client 两面共同承担;Session Controller 不携带其中任何实现,两面的编译与运行时入口保持独立。 +- 工作区文件访问由 `api/workspace-files` 的 Host/Client 两面共同承担;Session Controller 不携带其中任何实现,两面的编译与运行时入口保持独立。header-only Session scope 让普通、subagent、live 与 cold Session 都能解析自己的相对路径,不需要 Agent 生命周期,也不回退父 Session。 - 任意大小的文件都能打开:文本按行页、任何文件按字节窗口,在 Host 上各自只花一页或一窗内存;全文读取则受 `maxFileBytes` 约束;代价是消费者自己拼装页面,且单行超过 `maxBytes` 的行没有任何页,因为页按行切。 - 每个文件系统提供者现在都提供开窗的原始读取。`fs-e2b` 为此付出传输被跳过前缀的代价,因为其 SDK 不能 seek;`fs-local` 能 seek。 - 线路上的路径是规范的:`absolutePath` 与变更帧以符号链接已解析的拼法命名文件。跟随者绑定到成功的 `stat.absolutePath`,因此同一文件的另一种拼法——经符号链接到达的工作区根——也使用该规范变更键。 @@ -142,7 +142,7 @@ Client 导出向 `ctx.resources` 注册一个 `ResourceProvider<'file'>`,存 ## Testing -`packages/api/workspace-files/tests` 中的 Host spec 覆盖分页读取(整文件、嵌套路径、空文件、多字节 UTF-8、行窗口边界、缺省与被拒的 limit、保留回车)、字节窗口(缺省值、后面还有内容的中段窗口、恰好与变短的尾窗、越界与空文件、NUL 与非法 UTF-8 经 base64 往返、与 `stat` 一致的版本、作为 `too-large` 的上限、坏范围、远超上限的文件的一个窗口、无大小时推断的 `eof`)、`stat`、工作区外读取及后端拒绝、带包含限制、截断、符号链接子项与 `not-directory` 的 `list`,以及由 `fs/observed` 驱动并按根过滤的 `changes` 流。`packages/api/workspace-files/tests` 中的 Client spec 覆盖提供者的帧(开头 stat、失败帧、不带内容的写入、消失、恢复、中止)、变更流(每会话一条流、按归一路径扇出、排队的帧、因 signal 或 Host 关闭而结束)、不支持地址的各种情形,以及随 fiber 的注册与释放。`fs/fs`、`fs-local` 与 `fs-e2b` 的 spec 钉住 `readByteRange` 的范围语义——中段窗口、短于所求的尾窗、越界与零长窗口、错误、中止以及 e2b 的取消——`dsh-util-workspace-path` 的 spec 钉住文件地址语法。`readAll` 与 `readRelated` 的 spec 覆盖全文读取上限、工作区外基准与关联路径,以及 Host 后端授权。connection fixture 为 web e2e 套件提供 `stat`、分页 `read`、`list` 与一帧可选启用的 `changes`。 +`packages/api/workspace-files/tests` 中的 Host spec 覆盖 live 与 cold subagent Session 的 header-only scope 解析、部署 fallback、缺失身份与 lookup 释放;分页读取(整文件、嵌套路径、空文件、多字节 UTF-8、行窗口边界、缺省与拒绝的 limit、保留回车);字节窗口(缺省、中段与尾窗、越界与空文件、base64 往返、版本、上限、坏范围以及无大小时的 `eof`);`stat`;工作区外读取及后端拒绝;`list` 的包含、截断、符号链接与 `not-directory`;以及由 `fs/observed` 驱动并按根过滤的 `changes`。Client spec 覆盖提供者帧、变更流、不支持地址及注册与释放。`fs/fs`、`fs-local` 与 `fs-e2b` spec 钉住 `readByteRange`;`dsh-util-workspace-path` spec 钉住文件地址语法。connection fixture 为 web e2e 套件提供 `stat`、分页 `read`、`list` 与一帧可选启用的 `changes`。 ## Deferred diff --git a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml index 70fc2a365d..7f1944282d 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-08-document-preview-operations.md -2026-09-08-document-preview-operations.md: 316f33371d393846cce5b598ee23289cecd6c178 -2026-09-08-document-preview-operations.zh.md: 97759db32d139a44fe33fd2c5e2eb7ec0c8960fd +2026-09-08-document-preview-operations.md: 3703933273e743c8df32bf0352fc276fe21dcb93 +2026-09-08-document-preview-operations.zh.md: b4896e95959d0f276ee69dfeaee9528319981714 diff --git a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md index 316f33371d..3703933273 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md +++ b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md @@ -18,7 +18,7 @@ Readable files use `dsh-resource://file/session//`. The path ma [Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.md) owns format selection and loading policy. Metadata registers with `ctx.documentPreviews`; components register separately into the keyed `sidebar.right.tab.document` Slot. Extension registrations precede builtins, then longer suffixes and registration order decide. The toolbar lists matching alternatives and remembers a manual choice per tab; plain text is the fallback. The child receives accumulated text or complete native bytes, the original resource address, and the standard `useResource` and `useTabInfo` hooks. Preview calls existing `read`, `readAll`, and `readRelated` through ordinary injection and decodes bytes in its own `rpc.ts`. Refresh remains per tab, with no resource reload, shared `changed` acknowledgement, extra resource wrapper, or content Session. -Markdown and code reuse the incremental primitives with cumulative paged text. HTML and PDF read complete `Uint8Array` data; Host transport remains base64. Published buffers are borrowed read-only and never persist into layout or Session JSON. PDF.js runs in an owned Worker with version-matched bundled font and decoder data, and copies input before transfer to preserve Preview's retained buffer. HTML runs in a Blob iframe with `sandbox="allow-scripts"`, without same-origin, popup, form, download, or top-navigation privileges. The browser retains its normal external-network rules. Bounded static local JS/CSS reads stay in the parent; the opaque frame creates its own asset Blobs, because it cannot load parent-origin Blobs. Replacing the document replaces the browsing context and revokes its root Blob. +Markdown and code reuse the incremental primitives with cumulative paged text. HTML, PDF, and images read complete `Uint8Array` data; Host transport remains base64. Published buffers are borrowed read-only and never persist into layout or Session JSON. PDF.js runs in an owned Worker with version-matched bundled font and decoder data, and copies input before transfer to preserve Preview's retained buffer. HTML runs in a Blob iframe with `sandbox="allow-scripts"`, without same-origin, popup, form, download, or top-navigation privileges. The browser retains its normal external-network rules. Bounded static local JS/CSS reads stay in the parent; the opaque frame creates its own asset Blobs, because it cannot load parent-origin Blobs. PNG, JPEG, GIF, WebP, BMP, ICO, and SVG use image-specific Blob URLs in an `` static-image context. They retain intrinsic CSS-pixel dimensions; auto margins centre images smaller than the shared scroller, while larger dimensions extend its horizontal or vertical scroll range. The renderer provides no zoom or drag-to-pan. SVG markup never enters the application DOM or an iframe, so scripts remain inert and cannot reach the parent page. Replacing HTML or an image revokes its root Blob URL. ## Alternatives considered @@ -34,6 +34,8 @@ Markdown and code reuse the incremental primitives with cumulative paged text. H **A local server, virtual host, or `file:` iframe.** These require extra hosting or filesystem authority. The preview is for static generated pages, not a complete application runtime; modules, dynamic filesystem requests, and arbitrary nested asset graphs are outside its support. +**Sanitize SVG into the application DOM or an iframe.** A sanitizer would add a second SVG parser and an evolving active-content policy before placing untrusted markup in an interactive document. The `` static-image context preserves native SVG rendering and intrinsic dimensions without giving the markup a script-capable DOM. + ## Consequences -Renderers can be replaced without changing the tab or file protocol. Full-file formats pay bounded whole-file memory and PDF adds bundled Worker/font/decoder bytes. Format selection and view state are page-local, not durable Session data. Preview owns RPC cancellation and native buffers independently of metadata observation. A tab retains its read version and the observation version captured at read start; refreshing it neither discards another tab's content nor clears its change notice. File reads remain non-transactional, and opaque versions are compared for equality, not ordering. The [recorded browser scenario](../../../../apps/web/tests/document-preview.e2e.ts) exercises the shared toolbar, incremental text, isolated HTML dependencies, and lazy continuous PDF Worker rendering. +Renderers can be replaced without changing the tab or file protocol. Full-file formats pay bounded whole-file memory and PDF adds bundled Worker/font/decoder bytes. Format selection and view state are page-local, not durable Session data. Preview owns RPC cancellation and native buffers independently of metadata observation. A tab retains its read version and the observation version captured at read start; refreshing it neither discards another tab's content nor clears its change notice. File reads remain non-transactional, and opaque versions are compared for equality, not ordering. The [recorded browser scenario](../../../../apps/web/tests/document-preview.e2e.ts) exercises the shared toolbar, incremental text, isolated HTML dependencies, intrinsic raster and SVG rendering with two-axis scrolling, inert SVG scripts, and lazy continuous PDF Worker rendering. diff --git a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md index 97759db32d..b4896e9595 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md @@ -18,7 +18,7 @@ Document Preview 将资源观察与内容读取分开。[资源模型](2026-09-0 [Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md) 负责格式选择和加载策略。元数据通过 `ctx.documentPreviews` 注册;组件单独注册到 keyed `sidebar.right.tab.document` Slot。扩展注册优先于内置注册,其次比较后缀长度和注册顺序。工具栏列出匹配候选,按 tab 记住手动选择;纯文本是兜底。子组件收到累积文本或完整原生字节、原始资源地址,以及标准 `useResource` 和 `useTabInfo` 钩子。Preview 经普通注入调用既有 `read`、`readAll` 与 `readRelated`,在自己的 `rpc.ts` 解码字节。刷新仍按 tab 独立进行,不引入资源 reload、共享 `changed` 确认、额外资源包装层或内容 Session。 -Markdown 和代码通过累积的分页文本复用增量渲染原语。HTML 和 PDF 读取完整 `Uint8Array` 数据;Host 传输保持 base64。发布后的缓冲区只读借用,绝不持久化进布局或 Session JSON。PDF.js 在自有 Worker 中运行,字体和解码数据以相同版本随包发布,转移输入前先复制,以保留 Preview 的缓冲区。HTML 在 Blob iframe 中运行,设置 `sandbox="allow-scripts"`,不授予同源、弹窗、表单、下载或顶层导航权限。浏览器保持正常的外部网络规则。有上限的静态本地 JS/CSS 读取由父页面负责;不透明源 iframe 创建自己的资源 Blob,因为它不能加载父源创建的 Blob。替换文档会替换浏览上下文,并撤销其根 Blob。 +Markdown 和代码通过累积的分页文本复用增量渲染原语。HTML、PDF 和图片读取完整 `Uint8Array` 数据;Host 传输保持 base64。发布后的缓冲区只读借用,绝不持久化进布局或 Session JSON。PDF.js 在自有 Worker 中运行,字体和解码数据以相同版本随包发布,转移输入前先复制,以保留 Preview 的缓冲区。HTML 在 Blob iframe 中运行,设置 `sandbox="allow-scripts"`,不授予同源、弹窗、表单、下载或顶层导航权限。浏览器保持正常的外部网络规则。有上限的静态本地 JS/CSS 读取由父页面负责;不透明源 iframe 创建自己的资源 Blob,因为它不能加载父源创建的 Blob。PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 使用图片专用 Blob URL,在 `` 静态图片上下文中渲染。它们保留固有 CSS 像素尺寸;auto margin 让小于共享滚动区的图片居中,较大的尺寸则扩展横向或纵向滚动范围。渲染器不提供缩放或拖拽平移。SVG 标记绝不进入应用 DOM 或 iframe,因此脚本保持不可执行,也无法访问父页面。替换 HTML 或图片时会撤销其根 Blob URL。 ## 考虑过的替代方案 @@ -34,6 +34,8 @@ Markdown 和代码通过累积的分页文本复用增量渲染原语。HTML 和 **本地服务器、虚拟主机或 `file:` iframe。** 这些方案需要额外托管或文件系统权限。预览面向静态生成页面,而非完整应用运行时;模块、动态文件系统请求和任意嵌套资源图不在支持范围内。 +**清理 SVG 后放入应用 DOM 或 iframe。** sanitizer 会增加第二套 SVG parser 和一套持续演进的主动内容策略,之后仍要把不可信标记放进可交互文档。`` 静态图片上下文保留浏览器原生 SVG 渲染与固有尺寸,同时不给标记一个能运行脚本的 DOM。 + ## 影响 -替换渲染器不需要改变 Tab 或文件协议。全文格式承担有上限的整文件内存成本,PDF 增加随包发布的 Worker、字体和解码器字节。格式选择和查看状态仅属于当前页面,不是持久 Session 数据。Preview 独立于元数据观察,拥有 RPC 取消和原生缓冲区。tab 保留读取版本及读取开始时捕获的观察版本;刷新它既不丢弃其他 tab 的内容,也不清除其变更提示。文件读取仍非事务,不透明版本只比较相等性、不排序。[录制的浏览器场景](../../../../apps/web/tests/document-preview.e2e.ts) 覆盖共用工具栏、增量文本、隔离的 HTML 依赖,以及惰性连续 PDF Worker 渲染。 +替换渲染器不需要改变 Tab 或文件协议。全文格式承担有上限的整文件内存成本,PDF 增加随包发布的 Worker、字体和解码器字节。格式选择和查看状态仅属于当前页面,不是持久 Session 数据。Preview 独立于元数据观察,拥有 RPC 取消和原生缓冲区。tab 保留读取版本及读取开始时捕获的观察版本;刷新它既不丢弃其他 tab 的内容,也不清除其变更提示。文件读取仍非事务,不透明版本只比较相等性、不排序。[录制的浏览器场景](../../../../apps/web/tests/document-preview.e2e.ts) 覆盖共用工具栏、增量文本、隔离的 HTML 依赖、可双轴滚动的固有尺寸位图与 SVG 渲染、不可执行的 SVG 脚本,以及惰性连续 PDF Worker 渲染。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-07-win32-picker-foreground-alt-key.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-07-win32-picker-foreground-alt-key.i18n.yaml new file mode 100644 index 0000000000..0a54d83ab9 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-07-win32-picker-foreground-alt-key.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-07-win32-picker-foreground-alt-key.md +2026-09-07-win32-picker-foreground-alt-key.md: cd1bae56845c74bf8fb0b3c0e6b8e6bab8032daf +2026-09-07-win32-picker-foreground-alt-key.zh.md: 8cbacaee97892b2ba6feb3d0250212fa9d393376 diff --git a/.agents/notes/implemented/bug-fix/2026-09-07-win32-picker-foreground-alt-key.md b/.agents/notes/implemented/bug-fix/2026-09-07-win32-picker-foreground-alt-key.md new file mode 100644 index 0000000000..cd1bae5684 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-07-win32-picker-foreground-alt-key.md @@ -0,0 +1,25 @@ +# Agent Note: Foreground activation for the Win32 picker via a synthesized Alt press + +Status: implemented + +English | [中文](2026-09-07-win32-picker-foreground-alt-key.zh.md) + +## Problem + +The web GUI host picks a workspace directory through the native Win32 folder dialog, which runs in a child process the host spawns (issue #3543). Windows grants the foreground only to the foreground process, to a process it started, or to a process that received recent input; a child of a background server process qualifies for none of these, so the dialog that `Show` opens sits behind every visible window even though it is the child's first window. The first-window activation assumption behind the spawn design ([archived feature note](../../archived/feature/2026-08-02-win32-in-process-folder-dialog.md)) holds only when the spawner chain owns the console foreground, as in a console-launched CLI. + +## Decision + +`runFolderDialog` calls a new `pressAltForForeground` binding between the `showing` notice and the blocking `Show`. The binding synthesizes one Alt press (`keybd_event` with `VK_MENU`, down then up) on the dialog thread, which makes Windows count this process as the most recent input owner — one of the documented grounds for foreground activation — so the dialog window `Show` creates activates as foreground. The bindings module already loads koffi's `user32`, so the change adds one function fetch and two invocations. The press is unconditional on Windows. When the process already holds foreground rights (a console-launched CLI), the dialog activates anyway and the press is inert; the window focused at that moment still receives the lone Alt and may briefly highlight its menu bar. Environments that suppress injected input (secure desktops, restricted remote sessions, an elevated foreground window) leave the dialog behind other windows, and the package README records that limit. + +## Alternatives considered + +**Custom URL protocol with a browser click gesture.** Draft PR #3544 granted the foreground by navigating the foreground browser to a registered `dsh-picker://` URL, which makes the shell launch the dialog process as a foreground descendant. The grant is deterministic by design, but the mechanism spans registry and VBS launcher files, a protocol entry point, a picker-result HTTP route with per-boot tokens, and a first-use browser confirmation, and it adds a server route the browser can reach. The synthesized press removes that entire surface. + +**AllowSetForegroundWindow from the clicker.** The API must be called by the current foreground process — the browser — and may name only one permitted process; the spawner cannot invoke it on the browser's behalf. + +**AttachThreadInput to the focused thread.** Attaching the dialog thread to the focused window's thread also bypasses the foreground restriction and avoids the keystroke side effect, but it is equally undocumented, needs the focused window's thread id at show time, and fails when the focused window belongs to a higher-integrity process; it was not prototyped. + +## Consequences + +The picker keeps its single spawned-child design and gains foreground behavior in the background-host case at the cost of one koffi call pair. The bindings spec pins the Alt down/up sequence and its position immediately before `Show` over the fake COM world; the logic spec pins the full showing → press → `Show` order. The Windows CI lane still opens and abort-closes a real dialog with the press present but asserts no activation. Validation on a Windows 11 machine with the foreground lock forced to its maximum reproduced the failure without the press (dialog behind other windows) and the foreground dialog with it in five of five repeat runs; Windows 10 is unverified. Synthesized input is consumed asynchronously by the raw input thread, so the activation grant is in principle race-prone; no miss appeared across the repeat runs, and fragile environments stay a documented package limitation rather than a second mechanism, because the browse backend remains the composition-level answer where native picking cannot be trusted. diff --git a/.agents/notes/implemented/bug-fix/2026-09-07-win32-picker-foreground-alt-key.zh.md b/.agents/notes/implemented/bug-fix/2026-09-07-win32-picker-foreground-alt-key.zh.md new file mode 100644 index 0000000000..8cbacaee97 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-07-win32-picker-foreground-alt-key.zh.md @@ -0,0 +1,25 @@ +# Agent Note: 通过合成的 Alt 按键让 Win32 选择器获得前台激活 + +Status: implemented + +[English](2026-09-07-win32-picker-foreground-alt-key.md) | 中文 + +## Problem + +web GUI 宿主通过原生 Win32 文件夹对话框选择工作区目录,对话框运行在宿主 spawn 的子进程中(issue #3543)。Windows 只把前台授予前台进程、由前台进程启动的进程或最近收到输入的进程;后台服务器进程的子进程三者都不满足,因此 `Show` 打开的对话框即使属于子进程的首个窗口,也会落在所有可见窗口之后。spawn 设计背后的"首窗口即激活"假设([归档功能记录](../../archived/feature/2026-08-02-win32-in-process-folder-dialog.md))只在 spawn 链持有控制台前台时成立,例如控制台启动的 CLI。 + +## Decision + +`runFolderDialog` 在 `showing` 通知与阻塞式 `Show` 之间调用新增的 `pressAltForForeground` 绑定。该绑定在对话框线程上合成一次 Alt 按键(`keybd_event` 携带 `VK_MENU`,先按下后抬起),使 Windows 把该进程计为最近的输入所有者——文档记载的允许前台激活的理由之一——于是 `Show` 创建的对话框窗口以前台方式激活。bindings 模块已经加载 koffi 的 `user32`,因此改动只增加一次函数获取与两次调用。该按键在 Windows 上无条件执行。当进程已经持有前台权利(控制台启动的 CLI)时,对话框本来就会激活,按键不起作用;此刻获得焦点的窗口仍会收到这一次单独的 Alt,可能短暂高亮其菜单栏。在合成输入被抑制的环境(安全桌面、受限远程会话、提权前台窗口)中,对话框仍会落在其他窗口后面,包 README 记录了该限制。 + +## Alternatives considered + +**自定义 URL 协议加浏览器点击手势。** 草稿 PR #3544 通过让前台浏览器导航到已注册的 `dsh-picker://` URL 来授予前台,使 shell 把对话框进程作为前台进程的后代启动。该授权按设计具有确定性,但机制横跨注册表与 VBS 启动器文件、协议入口点、携带每次启动令牌的 picker-result HTTP 路由,以及首次使用时的浏览器确认,还增加了一条浏览器可达的服务端路由。合成按键删除了整个这一面。 + +**由点击方调用 AllowSetForegroundWindow。** 该 API 必须由当前前台进程——浏览器——调用,并且只能点名一个被允许的进程;spawner 无法代替浏览器调用它。 + +**对聚焦线程 AttachThreadInput。** 把对话框线程附着到焦点窗口所属线程同样能绕过前台限制且没有按键副作用,但同样没有文档契约,需要在显示时取得焦点窗口的线程 id,并在焦点窗口属于更高完整性进程时失败;未做原型验证。 + +## Consequences + +选择器保持单一 spawn 子进程设计,并在后台宿主场景获得前台行为,代价是一次 koffi 调用对。bindings spec 在假 COM 世界上固定了 Alt 按下/抬起序列及其紧邻 `Show` 之前的位置;logic spec 固定完整的 showing → press → `Show` 顺序。Windows CI lane 仍会真实打开并中止关闭一个对话框(按键存在),但不断言激活。在一台把前台锁强制到最大值的 Windows 11 机器上做了验证:不加按键复现失败(对话框在其他窗口后面),加上按键后对话框获得前台,五轮重复全部成功;Windows 10 尚未验证。合成输入由 raw input thread 异步消费,因此激活授权原则上存在竞争窗口;重复运行中未出现错过,脆弱环境仍是记录的包限制而非第二套机制,因为浏览后端仍是原生选择不可信场景在组合层面的答案。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.i18n.yaml new file mode 100644 index 0000000000..f6a8425e96 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.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-08-stable-room-reading-under-hidden-split-controls.md +2026-09-08-stable-room-reading-under-hidden-split-controls.md: c89c5328ee896e23ed24454c50223193c2d587ba +2026-09-08-stable-room-reading-under-hidden-split-controls.zh.md: edf9948e547b79b33165b9c7168238e6cf1d2685 diff --git a/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.md b/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.md new file mode 100644 index 0000000000..c89c5328ee --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.md @@ -0,0 +1,25 @@ +# Agent Note: Keep the room reading independent of hidden split controls + +Status: implemented + +English | [中文](2026-09-08-stable-room-reading-under-hidden-split-controls.zh.md) + +## Problem + +The dockkit room rule measures each pane's tab strip after every commit to decide whether an equal split leaves two working halves. With `hideSplitWhenBlocked`, a width-blocked pane unmounts its split control — and the unmount changes the very strip the rule measured: the strip sheds the control's 28px box plus its 4px gap, the fixed part shrinks, and the same pane reads as fitting again. Remounting the control reverses the reading. Across a roughly 32px band of pane widths the two states alternate inside nested layout effects until React stops the update loop (error #185); the slot runtime catches the crash and unmounts the Sidebar's entry while the column still records itself expanded, so neither the panel nor the header's collapsed-only expand button renders. A grip drag on a squeezed viewport sweeps the panel through that band, which presented as the whole sidebar vanishing with no way back in. + +## Decision + +When the embedder hides blocked split controls, the room rule leaves the split control's footprint out of the strip's fixed part unconditionally, so the reading is the same whether the control is currently mounted or not. [`measurePaneFits`](../../../../packages/client/ui-dockkit/src/components/measure.ts) takes the embedder's `hideSplitWhenBlocked` choice, measures the rendered control's box plus the strip's column gap (`splitControlFootprint`), and passes it as [`PaneMeasure.splitControlWidth`](../../../../packages/client/ui-dockkit/src/engine/geometry.ts), which `halvesFit` subtracts from the fixed part. Excluding the footprint is also correct on its own terms: a half too narrow to split would hide its own control, so the footprint is not part of what a half must carry. Embedders that render blocked controls disabled pass nothing and keep the control in the fixed part, as before. + +## Alternatives considered + +**Hide only budget-blocked controls, render width-blocked ones disabled.** This is what the code did before `hideSplitAtCapacity` widened into `hideSplitWhenBlocked`: the budget is state-driven and cannot feed back through the measurement. It avoids the loop but forfeits the Sidebar's requested presentation — no disabled split control on panes that cannot split. + +**Debounce or freeze re-measurement during oscillation.** Damping hides the instability instead of removing it: the reading would still depend on the control's visibility, settle on an arbitrary one of the two states, and flip on the next resize. + +**Measure the control's footprint from a constant.** A hardcoded 32px drifts from the stylesheet; measuring the rendered control and the strip's real `column-gap` keeps the subtraction equal to what the strip actually sheds, which is the exact condition for a stable reading. + +## Consequences + +The room reading is a fixed point under control visibility, so `hideSplitWhenBlocked` embedders get hidden controls without feedback. Panes near the boundary now read as splittable slightly earlier than a disabled-control embedder would report, because the half being asked about would not carry the control. A [dockkit regression test](../../../../packages/client/ui-dockkit/tests/components.client.spec.tsx) emulates the strip shedding the control's footprint and fails with React's update-depth error on the unfixed code; a [Sidebar browser case](../../../../apps/web/tests/sidebar-right.e2e.ts) drags the panel grip past both clamps on a squeezed viewport and asserts the panel, its grip, and a clean console survive, because the crash surfaces only as a console error the scaffold tripwire does not watch. diff --git a/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.zh.md b/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.zh.md new file mode 100644 index 0000000000..edf9948e54 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.zh.md @@ -0,0 +1,25 @@ +# Agent Note: Keep the room reading independent of hidden split controls + +Status: implemented + +[English](2026-09-08-stable-room-reading-under-hidden-split-controls.md) | 中文 + +## Problem + +dockkit 的空间规则在每次 commit 后测量各 pane 的标签条,判断等分后的两半是否仍可用。开启 `hideSplitWhenBlocked` 时,宽度不足的 pane 会卸载自己的分屏控件——而这次卸载恰恰改变了规则所测量的标签条:标签条少了控件的 28px 盒子加 4px 间距,固定部分随之变小,同一个 pane 又被读成"够宽"。控件重新挂载后读数再次反转。在约 32px 的 pane 宽度区间内,两种状态在嵌套 layout effect 中来回切换,直到 React 中止更新循环(错误 #185);slot 运行时捕获崩溃后卸载 Sidebar 的条目,而列状态仍记录为展开,于是面板和 header 上仅折叠时显示的展开按钮都不再渲染。在收窄的视口上拖动把手会让面板扫过该区间,表现为整个侧栏消失且无法再打开。 + +## Decision + +当嵌入方选择隐藏被阻止的分屏控件时,空间规则无条件将分屏控件的占位排除在标签条固定部分之外,使读数与控件当前是否挂载无关。[`measurePaneFits`](../../../../packages/client/ui-dockkit/src/components/measure.ts) 接收嵌入方的 `hideSplitWhenBlocked` 选择,测量已渲染控件的盒子加标签条的列间距(`splitControlFootprint`),并作为 [`PaneMeasure.splitControlWidth`](../../../../packages/client/ui-dockkit/src/engine/geometry.ts) 传入,由 `halvesFit` 从固定部分中减去。排除该占位本身也是正确的:窄到无法分屏的一半会隐藏自己的控件,所以这份占位并不属于一半必须承载的内容。将被阻止控件渲染为禁用态的嵌入方不传该值,控件照旧计入固定部分。 + +## Alternatives considered + +**只隐藏预算受限的控件,宽度受限的渲染为禁用态。** 这是 `hideSplitAtCapacity` 扩展为 `hideSplitWhenBlocked` 之前的做法:预算由状态驱动,不会经测量反馈回来。它避免了循环,但放弃了 Sidebar 想要的呈现——无法分屏的 pane 上不出现禁用的分屏控件。 + +**在振荡期间对重新测量做防抖或冻结。** 阻尼只是掩盖不稳定而非消除它:读数仍依赖控件的可见性,会任意停在两种状态之一,并在下次 resize 时再次翻转。 + +**用常量表示控件占位。** 硬编码的 32px 会与样式表漂移;测量实际渲染的控件和标签条真实的 `column-gap`,才能保证减去的量恰好等于标签条实际卸下的量,这正是读数稳定的确切条件。 + +## Consequences + +空间读数在控件可见性变化下是不动点,`hideSplitWhenBlocked` 的嵌入方获得隐藏控件的呈现且无反馈循环。临界宽度附近的 pane 会比禁用态嵌入方的报告稍早读成可分屏,因为被询问的那一半不会承载控件。[dockkit 回归测试](../../../../packages/client/ui-dockkit/tests/components.client.spec.tsx) 模拟标签条卸下控件占位的反馈,在未修复的代码上以 React 更新深度错误失败;[Sidebar 浏览器用例](../../../../apps/web/tests/sidebar-right.e2e.ts) 在收窄视口上把面板把手拖过两侧钳位,断言面板、把手与干净的 console 均存活——崩溃只以 console error 形式出现,而脚手架的 tripwire 不监听它。 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-07-07-mcp-client-plugin.i18n.yaml b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml index 67227acc29..c5435129be 100644 --- a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md -2026-07-07-mcp-client-plugin.md: 1c451e14ea2a7c494454956ce9bb09d97d4e1e1d -2026-07-07-mcp-client-plugin.zh.md: 78a086d6c218a254e850777fe4024973e8848e17 +2026-07-07-mcp-client-plugin.md: 91c1bf428973eae7935e9c2a14dda7028a734313 +2026-07-07-mcp-client-plugin.zh.md: c0c2dbae5b854734240f8035edfc8d0b5a33408f diff --git a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md index 1c451e14ea..91c1bf4289 100644 --- a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md +++ b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md @@ -96,12 +96,14 @@ Every MCP tool has two names: This server-qualified shape is the de-facto standard among multi-server agent clients — every surveyed end-user product qualifies MCP tools by server ([Claude Code](https://code.claude.com/docs/en/agent-sdk/mcp#tool-naming-convention) `mcp__github__list_issues`, [Codex](https://openai.com/index/unrolling-the-codex-agent-loop/) `mcp__weather__get-forecast`, [Gemini CLI](https://geminicli.com/docs/tools/mcp-server/#3-tool-naming-and-namespaces), [VS Code](https://github.com/microsoft/vscode/blob/ab9ec62c6a61e429a9abd612ff220c3f4834c9ea/src/vs/workbench/contrib/mcp/common/mcpServer.ts#L217-L260), [Cline](https://github.com/cline/cline/blob/52fdbb1d72f7324a28142a7ba7678d4b53c902f4/sdk/packages/core/src/extensions/mcp/name-transform.ts#L20-L35), [Roo Code](https://github.com/RooCodeInc/Roo-Code/blob/b867ec9145750d0ae1ff7f02d35406e9bf2a0b16/src/utils/mcp-name.ts#L117-L140), [Goose](https://github.com/block/goose/blob/b3a012cbdde854b0fe14f95b1c48543bf6517c0a/crates/goose/src/agents/extension_manager.rs#L1391-L1441), [OpenCode](https://github.com/anomalyco/opencode/blob/d199b1bff90282a4f9cd6251b5fc7b16875a52f6/packages/opencode/src/mcp/catalog.ts#L117-L120)); the exact `mcp____` spelling follows Claude Code and Codex. The `mcp__` marker keeps MCP registrations out of the native tools' namespace and gives permission/telemetry rules a stable shape (`mcp__*`, `mcp__github__*`). -1. On connect: drain `client.listTools()` pagination, derive every tool's `publicName`, then register each as a raw `ToolDefinition` via `ctx.tools.register()`. The MCP JSON Schema and description pass through unchanged (no `defineTool` DSL conversion); only the model-facing `name` is replaced. +1. On connect: drain uncached `tools/list` pagination, derive every tool's `publicName`, then register each as a raw `ToolDefinition` via `ctx.tools.register()`. The MCP JSON Schema and description pass through unchanged (no `defineTool` DSL conversion); only the model-facing `name` is replaced. 2. Listen for `notifications/tools/list_changed` → re-run the same sync (dispose previous generation, register new). Deterministic names mean unchanged tools keep their names across re-syncs. 3. The executor closes over `rawName`; the public name is never sent to the server and never parsed to recover the raw name. 4. No `presentCall`/`presentResult` — UI consumers use the provider-neutral generic-card fallback. 5. Tools are transparent in the system prompt — no "[via MCP]" annotation beyond the name itself. +Each synchronization rejects a repeated non-empty continuation cursor before requesting another page, retaining the previous tool generation. Empty pages cannot establish progress through tool-name uniqueness, so cursor history also detects cycles spanning several pages ([reported failure](https://github.com/deepseek-ai/deepseek-harness/discussions/3660)). Cursor history belongs to one synchronization: a later update may reuse the same cursors. Focused bridge and lifecycle tests cover cycle rejection, retained callable tools, strict startup failure, and notification recovery. This detects repeated cursors; it does not bound a server that continually returns distinct cursors. + ### Public name normalization MCP allows tool names up to 128 characters including `.`; the DeepSeek function-name contract allows `[A-Za-z0-9_-]` and at most 64. Public names are normalized deterministically: invalid characters become `_`, and when replacement or truncation changed the name, a 12-hex-char SHA-256 hash of the `(serverName, rawName)` identity is appended so distinct MCP identities can never collapse into the same public name: diff --git a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md index 78a086d6c2..c0c2dbae5b 100644 --- a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md +++ b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md @@ -96,12 +96,14 @@ type Config = StdioConfig | StreamableHttpConfig 这种按服务器限定的形式是多服务器 agent 客户端事实上的标准——所有被调研的终端用户产品都按服务器限定 MCP 工具名([Claude Code](https://code.claude.com/docs/en/agent-sdk/mcp#tool-naming-convention) `mcp__github__list_issues`、[Codex](https://openai.com/index/unrolling-the-codex-agent-loop/) `mcp__weather__get-forecast`、[Gemini CLI](https://geminicli.com/docs/tools/mcp-server/#3-tool-naming-and-namespaces)、[VS Code](https://github.com/microsoft/vscode/blob/ab9ec62c6a61e429a9abd612ff220c3f4834c9ea/src/vs/workbench/contrib/mcp/common/mcpServer.ts#L217-L260)、[Cline](https://github.com/cline/cline/blob/52fdbb1d72f7324a28142a7ba7678d4b53c902f4/sdk/packages/core/src/extensions/mcp/name-transform.ts#L20-L35)、[Roo Code](https://github.com/RooCodeInc/Roo-Code/blob/b867ec9145750d0ae1ff7f02d35406e9bf2a0b16/src/utils/mcp-name.ts#L117-L140)、[Goose](https://github.com/block/goose/blob/b3a012cbdde854b0fe14f95b1c48543bf6517c0a/crates/goose/src/agents/extension_manager.rs#L1391-L1441)、[OpenCode](https://github.com/anomalyco/opencode/blob/d199b1bff90282a4f9cd6251b5fc7b16875a52f6/packages/opencode/src/mcp/catalog.ts#L117-L120));`mcp____` 的拼写方式与 Claude Code 和 Codex 一致。`mcp__` 前缀将 MCP 注册与原生工具的命名空间隔离,并为权限/遥测规则提供稳定的匹配模式(`mcp__*`、`mcp__github__*`)。 -1. 连接时:遍历 `client.listTools()` 的分页结果,推导每个工具的 `publicName`,然后通过 `ctx.tools.register()` 将其注册为原始 `ToolDefinition`。MCP 的 JSON Schema 和描述原样透传(不做 `defineTool` DSL 转换);仅替换模型可见的 `name`。 +1. 连接时:遍历未缓存的 `tools/list` 分页结果,推导每个工具的 `publicName`,然后通过 `ctx.tools.register()` 将其注册为原始 `ToolDefinition`。MCP 的 JSON Schema 和描述原样透传(不做 `defineTool` DSL 转换);仅替换模型可见的 `name`。 2. 监听 `notifications/tools/list_changed` → 重新执行同步(dispose 上一代、注册新一代)。确定性命名意味着未变化的工具在重新同步后保持原名。 3. 执行器闭包持有 `rawName`;公开名称永远不发送给服务器,也永远不被解析以还原原始名称。 4. 无 `presentCall`/`presentResult`——UI 消费方使用提供方无关的通用卡片兜底。 5. 工具在系统提示词中是透明的——除名称本身外不附加「[via MCP]」标注。 +每次同步在请求下一页前拒绝重复的非空续传游标,并保留上一代工具。空页无法通过工具名唯一性证明分页在前进,因此游标记录还会检测跨越多页的循环([问题报告](https://github.com/deepseek-ai/deepseek-harness/discussions/3660))。游标记录只属于一次同步:后续更新可以复用相同游标。定向桥接与生命周期测试覆盖循环拒绝、保留可调用工具、严格启动失败及通知恢复。此机制检测重复游标;它不限制持续返回不同游标的服务器。 + ### 公开名称规范化 MCP 允许工具名最长 128 字符且可包含 `.`;DeepSeek 的函数名约定允许 `[A-Za-z0-9_-]` 且最多 64 字符。公开名称按确定性规则规范化:非法字符替换为 `_`,当替换或截断改变了名称时,追加 `(serverName, rawName)` 标识的 12 位十六进制 SHA-256 hash,确保不同的 MCP 标识永远不会坍缩为同一个公开名称: diff --git a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml index cbe920b068..13820af392 100644 --- a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md -2026-07-12-subagent-persona-tool-filter-and-depth.md: 511e340c81b811ebcaaea48946c377c99c53da54 -2026-07-12-subagent-persona-tool-filter-and-depth.zh.md: 8a213f33a9b70a9bdec6f23b5bec4db5d94f110f +2026-07-12-subagent-persona-tool-filter-and-depth.md: f848b92a7a5647995330e2bf26796dde1b43a3f8 +2026-07-12-subagent-persona-tool-filter-and-depth.zh.md: 3360b27c9feb5950cc3f338c89e48a4c902e2ac8 diff --git a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md index 511e340c81..f848b92a7a 100644 --- a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md +++ b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md @@ -34,7 +34,7 @@ This uses the normal system-prompt registration mechanism rather than a second p ### Tool filtering is one live global-view rule -The tool filter controls capability visibility and executable lookup together. An in-process provider installs `ToolRuntime.restrict()` in the child's scope before publication, and the registry's single resolver applies the same result to wire tool schemas, lookup, execution, and PTC mode SDK generation. Independently registered system-prompt sections are outside `ToolRuntime`, so filtering a tool does not remove that plugin's standalone guidance. +The tool filter controls capability visibility and executable lookup together. An in-process provider installs `ToolRuntime.restrict()` in the child's scope before publication, and the registry's single resolver applies the same result to wire tool schemas, lookup, execution, and PTC mode SDK generation. Independently registered system-prompt sections remain owned by their plugins. The filesystem, search, and web tool plugins use the existing `PromptSection.text({ scope })` callback and `ctx.tools.get(name, scope)` to omit guidance for unavailable tools and select applicable cross-tool text. This keeps the original wording and ordering for a supported tool set and works for any agent scope, including underlying PTC capabilities whose wire presentation is `run_code`. It adds no section-ownership metadata or assembly pass; unrelated static prose is not automatically rewritten by `restrict()`. Resolution follows these rules: diff --git a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md index 8a213f33a9..3360b27c9f 100644 --- a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md +++ b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md @@ -36,7 +36,7 @@ subagent 启动有三个独立的组合控制:`persona`、`toolFilter` 和 `ma ### 工具过滤是一条作用于实时全局视图的规则 -工具过滤同时控制能力可见性和可执行查找。进程内提供方在发布前于子 agent 作用域中安装 `ToolRuntime.restrict()`,注册表的单一解析器对协议格式(wire format)的工具 schema、查找、执行和 PTC mode SDK 生成施加相同的结果。独立注册的系统提示词段落不在 `ToolRuntime` 内,因此过滤一个工具不会移除该插件的独立指导文本。 +工具过滤同时控制能力可见性和可执行查找。进程内提供方在发布前于子 agent 作用域中安装 `ToolRuntime.restrict()`,注册表的单一解析器对协议格式(wire format)的工具 schema、查找、执行和 PTC mode SDK 生成施加相同的结果。独立注册的系统提示词段落仍由各插件负责。文件系统、搜索和 Web 工具插件使用已有的 `PromptSection.text({ scope })` 回调与 `ctx.tools.get(name, scope)`,省略不可用工具的指导,并选择适用的跨工具文本。这会保留受支持工具集合下的原有措辞与顺序,适用于任意 agent scope,也包括协议呈现为 `run_code` 的底层 PTC 能力。该方式不新增段落归属元数据或组装步骤;`restrict()` 不会自动改写其他静态文字。 解析遵循以下规则: diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml index 77dbd7cc75..ccf2075105 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-agent-teams.md -2026-08-05-agent-teams.md: f824fc937a9e8ff55016461bd291843b23a5ff41 -2026-08-05-agent-teams.zh.md: 7f85268495d636a352a2fa74a111edf65d749893 +2026-08-05-agent-teams.md: 1fcbe9c586b78db3d5638074e51745c8c189c80c +2026-08-05-agent-teams.zh.md: 5520b9c1e7fa039be677881f4d2df504f835ae81 diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md index f824fc937a..1fcbe9c586 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md @@ -10,13 +10,13 @@ The subagent seam supplies fresh/fork providers, durable child Sessions, FIFO fo All same-process Agents also share one checkout. Filesystem edit tools can reject an observed stale version, but Bash, formatters, generators, and external writers bypass that fence. Treating a teammate name or task owner as a file lock would hide rather than solve this concurrency boundary. -Agent Teams needs an explicit source-checkout composition before its public contracts are stable enough for released CLI or Web bundles. The default tool catalog and simple-task behavior must remain unchanged, while an explicitly requested Team must survive child Activation settlement and mailbox delivery races long enough for the Lead to aggregate the result before process teardown. +Agent Teams needs an explicit opt-in composition while its public contracts remain experimental. The default tool catalog and simple-task behavior must remain unchanged, while an explicitly requested Team must survive child Activation settlement and mailbox delivery races long enough for the Lead to aggregate the result before process teardown. ## Decision Every ordinary runtime root is the implicit Lead of a Team identified by that root's `SessionId`. The Team has no creation event: its Lead pseudo-row exists by identity, while durable state begins with the first member, message, or task event. A roster is flat and contains at most the configured number of immutable lowercase-kebab-case names. Each teammate is a continuable direct child with a reserved Session id; only the Lead creates or interrupts teammates. Ordinary provider-owned subagents outside the roster are not Team members, and an ordinary fork is a new root whose inherited Team records are excluded by their ancestor `TeamId`. -The implementation is split into `@deepseek-ai/dsh-experimental-agent-team`, which owns `ctx.agentTeams` and durable semantics, and `@deepseek-ai/dsh-experimental-tool-agent-team`, which owns scoped schemas and model guidance. Every Team tool declares its complete result schema and renders that value as compact JSON, so the compiler checks each `execute` against what the model is promised and no result spends tokens on indentation. Deployments mount both plugins explicitly and may disable legacy continuable controls with the same model-visible names. The explicit delegation policy permits Team creation only when the user asks for Agent Teams or teammates. Both packages are private members of `packages/experimental/`; the [experimental package decision](../architecture/2026-08-18-experimental-agent-teams-packages.md) owns release exclusion, dependency isolation, and promotion. +The implementation is split into `@deepseek-ai/dsh-experimental-agent-team`, which owns `ctx.agentTeams` and durable semantics, and `@deepseek-ai/dsh-experimental-tool-agent-team`, which owns scoped schemas and model guidance. Every Team tool declares its complete result schema and renders that value as compact JSON, so the compiler checks each `execute` against what the model is promised and no result spends tokens on indentation. Deployments mount both plugins explicitly and may disable legacy continuable controls with the same model-visible names. The explicit delegation policy permits Team creation only when the user asks for Agent Teams or teammates. Both packages are public members of `packages/experimental/`; the [experimental package decision](../architecture/2026-08-18-experimental-agent-teams-packages.md) owns publication, dependency isolation, and promotion. The Lead must wait for required work before its final answer. Process teardown remains the final lifecycle owner and drains continuation Activations; a Team task owner is durable state and is not automatically released by idle, interruption, or process exit. @@ -54,7 +54,7 @@ Worktree isolation is not a harness runtime behavior. A deployment or prompt may **Create isolated worktrees automatically.** Rejected because worktree creation, branch naming, merge policy, ignored files, build artifacts, and cleanup are deployment choices. It also changes the same-world behavior existing subagents and sandboxes expose. -**Enable Teams in the default catalog.** Rejected because scoped Team controls would shadow same-named legacy globals and unsolicited delegation would add latency and token cost to simple tasks. A private profile bundle inserts Team and disables the legacy controls without adding Team packages to shipped dependency graphs. +**Enable Teams in the default catalog.** Rejected because scoped Team controls would shadow same-named legacy globals and unsolicited delegation would add latency and token cost to simple tasks. An opt-in profile bundle inserts Team and disables the legacy controls without adding Team packages to shipped dependency graphs. **Use an in-memory board and mailbox.** Rejected because child settlement, HMR, and process interruption would lose accepted coordination state and make retries ambiguous. @@ -62,7 +62,7 @@ Worktree isolation is not a harness runtime behavior. A deployment or prompt may ## Testing -Package tests cover identity, name and authority checks, provider selection, reserved-id persistence collisions, child-before-Lead flush ordering, durable provisioning failure and pending-inbox JSONL reconciliation, concurrent target-local ordering, pending/history de-duplication, mailbox limits, post-flush notification, bounded disposal with in-flight creation and dispatch cancellation, failed-member cleanup, task CAS and DAG validation, write-scope warnings, wait cancellation/timeout, inbox-preserving interruption, ordinary-fork isolation, legacy-control shadowing, compact declared-schema result rendering, and scoped registration HMR at per-file 100% coverage. A keyless product snapshot loads the private Agent Teams profile bundle through `dsh --profile headless` and pins its complete model-visible tool list, Team policy, and durable workflow projection for two teammates, dependent tasks, peer delivery, waiting, completion, and aggregation. A CLI e2e reuses the same deterministic adapter and verifies normal process exit with persisted Team and child logs. +Package tests cover identity, name and authority checks, provider selection, reserved-id persistence collisions, child-before-Lead flush ordering, durable provisioning failure and pending-inbox JSONL reconciliation, concurrent target-local ordering, pending/history de-duplication, mailbox limits, post-flush notification, bounded disposal with in-flight creation and dispatch cancellation, failed-member cleanup, task CAS and DAG validation, write-scope warnings, wait cancellation/timeout, inbox-preserving interruption, ordinary-fork isolation, legacy-control shadowing, compact declared-schema result rendering, and scoped registration HMR at per-file 100% coverage. A keyless product snapshot loads the Agent Teams profile bundle through `dsh --profile headless` and pins its complete model-visible tool list, Team policy, and durable workflow projection for two teammates, dependent tasks, peer delivery, waiting, completion, and aggregation. A CLI e2e reuses the same deterministic adapter and verifies normal process exit with persisted Team and child logs. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md index 7f85268495..5520b9c1e7 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md @@ -10,13 +10,13 @@ subagent seam 已提供 fresh/fork provider、持久 child Session、FIFO foll 同进程 Agent 还共享一个 checkout。文件系统 edit 工具可以拒绝已观察到的陈旧版本,但 Bash、formatter、generator 与外部 writer 会绕过该屏障。把 teammate name 或 task owner 当作文件锁只会掩盖而不是解决该并发边界。 -在公开约定稳定到足以进入已发布 CLI 或 Web bundle 前,Agent Teams 需要显式的源码 checkout 组合。默认工具目录与简单任务行为必须保持不变;而显式请求的 Team 必须能跨越 child Activation settlement 与 mailbox 投递竞争,使 Lead 在进程 teardown 前汇总结果。 +Agent Teams 的公开约定仍处于实验阶段,因此需要显式启用的组合。默认工具目录与简单任务行为必须保持不变;而显式请求的 Team 必须能跨越 child Activation settlement 与 mailbox 投递竞争,使 Lead 在进程 teardown 前汇总结果。 ## Decision 每个普通运行时 Root 都是一个隐式 Team 的 Lead,Team id 等于该 Root 的 `SessionId`。Team 没有 creation event:Lead pseudo-row 由身份直接存在,持久状态从第一条 member、message 或 task event 开始。roster 是扁平结构,最多包含配置数量、不可变且采用小写 kebab-case 的名字。每个 teammate 都是使用预留 Session id 的 continuable 直接 child;只有 Lead 可以创建或 interrupt teammate。roster 外由 provider 管理的普通 subagent 不是 Team member;普通 fork 是新的 Root,继承的 Team 记录会因 ancestor `TeamId` 被排除。 -实现拆分为 `@deepseek-ai/dsh-experimental-agent-team` 与 `@deepseek-ai/dsh-experimental-tool-agent-team`:前者负责 `ctx.agentTeams` 和持久语义,后者负责 scoped schema 与模型指引。每个 Team 工具都声明完整的结果 schema,并把该值渲染为紧凑 JSON,因此编译器会检查每个 `execute` 是否符合对模型的承诺,也没有结果把 token 花在缩进上。部署显式挂载两个插件,并可禁用具有相同模型可见名称的旧 continuable control。显式 delegation 策略只允许在用户要求 Agent Teams 或 teammate 时创建 Team。 两个包都是 `packages/experimental/` 的私有成员;[实验性包决策](../architecture/2026-08-18-experimental-agent-teams-packages.zh.md)负责发布排除、依赖隔离与 promotion。 +实现拆分为 `@deepseek-ai/dsh-experimental-agent-team` 与 `@deepseek-ai/dsh-experimental-tool-agent-team`:前者负责 `ctx.agentTeams` 和持久语义,后者负责 scoped schema 与模型指引。每个 Team 工具都声明完整的结果 schema,并把该值渲染为紧凑 JSON,因此编译器会检查每个 `execute` 是否符合对模型的承诺,也没有结果把 token 花在缩进上。部署显式挂载两个插件,并可禁用具有相同模型可见名称的旧 continuable control。显式 delegation 策略只允许在用户要求 Agent Teams 或 teammate 时创建 Team。两个包都是 `packages/experimental/` 的公开成员;[实验性包决策](../architecture/2026-08-18-experimental-agent-teams-packages.zh.md)负责发布、依赖隔离与 promotion。 Lead 必须等待所需工作后才能给出最终答案。进程 teardown 仍是最终生命周期 owner,并会 drain continuation Activation;Team task owner 是持久状态,不会因 idle、interrupt 或进程退出自动释放。 @@ -54,7 +54,7 @@ Worktree isolation 不是 harness runtime 行为。deployment 或 prompt 可以 **自动创建隔离 worktree。** 拒绝,因为 worktree 创建、branch 命名、merge 策略、ignored file、构建产物与 cleanup 都是 deployment 选择;它也会改变既有 subagent 与 sandbox 暴露的 same-world 行为。 -**在默认工具目录中启用 Team。** 拒绝,因为 scoped Team control 会覆盖同名旧全局工具,主动 delegation 也会给简单任务增加延迟和 token 成本。私有 profile bundle 会插入 Team 并禁用旧 control,同时不向已发布依赖图添加 Team 包。 +**在默认工具目录中启用 Team。** 拒绝,因为 scoped Team control 会覆盖同名旧全局工具,主动 delegation 也会给简单任务增加延迟和 token 成本。opt-in profile bundle 会插入 Team 并禁用旧 control,同时不向随附依赖图添加 Team 包。 **使用内存 task board 与 mailbox。** 拒绝,因为 child settlement、HMR 与进程中断会丢失已接受协调状态,并让重试变得含糊。 @@ -62,7 +62,7 @@ Worktree isolation 不是 harness runtime 行为。deployment 或 prompt 可以 ## Testing -Package test 以逐文件 100% coverage 覆盖身份、名字与权限检查、provider 选择、预留 id 持久化冲突、child-before-Lead flush 顺序、持久 provisioning 失败与 pending-inbox JSONL 对账、target-local 并发顺序、pending/history 去重、mailbox 限额、flush 后 notification、取消在途创建与 dispatch 的有界 dispose、failed member cleanup、task CAS 与 DAG 校验、write-scope warning、wait cancel/timeout、保留 inbox 的 interrupt、普通 fork 隔离、旧 control shadowing、声明 schema 的紧凑结果渲染与 scoped registration HMR。一条 keyless 产品快照会通过 `dsh --profile headless` 加载私有 Agent Teams profile bundle,并为两个 teammate、依赖任务、peer 投递、等待、完成和汇总固定完整的面向模型工具列表、Team policy 与持久 workflow 投影。CLI e2e 会复用同一个确定性 adapter,并验证带持久 Team 与 child 日志的正常退出。 +Package test 以逐文件 100% coverage 覆盖身份、名字与权限检查、provider 选择、预留 id 持久化冲突、child-before-Lead flush 顺序、持久 provisioning 失败与 pending-inbox JSONL 对账、target-local 并发顺序、pending/history 去重、mailbox 限额、flush 后 notification、取消在途创建与 dispatch 的有界 dispose、failed member cleanup、task CAS 与 DAG 校验、write-scope warning、wait cancel/timeout、保留 inbox 的 interrupt、普通 fork 隔离、旧 control shadowing、声明 schema 的紧凑结果渲染与 scoped registration HMR。一条 keyless 产品快照会通过 `dsh --profile headless` 加载 Agent Teams profile bundle,并为两个 teammate、依赖任务、peer 投递、等待、完成和汇总固定完整的面向模型工具列表、Team policy 与持久 workflow 投影。CLI e2e 会复用同一个确定性 adapter,并验证带持久 Team 与 child 日志的正常退出。 ## Consequences diff --git a/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.i18n.yaml b/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.i18n.yaml index 71a7ed9371..0451d6edcb 100644 --- a/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.md -2026-08-31-cross-process-session-write-lease.md: ef8ebe2de6b231075dee31a9184bcc0a5b323011 -2026-08-31-cross-process-session-write-lease.zh.md: 8a79b6d5327c7bd5d0ca12ac435140bc0951e993 +2026-08-31-cross-process-session-write-lease.md: bf02ce3eddfb5bcfe2a7d6bd6a6701c47d5f28a4 +2026-08-31-cross-process-session-write-lease.zh.md: b287ab7d6cb4202745fd1112bcb313cb261d674e diff --git a/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.md b/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.md index ef8ebe2de6..bf02ce3edd 100644 --- a/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.md +++ b/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.md @@ -10,7 +10,7 @@ The JSONL backend's write-handle claim excluded a second writer only inside one ## Decision -`SessionWriteLease` (packages/session/session-persistence-jsonl/src/lease.ts) holds a kernel lock on `session.lock` beside the log for the whole life of a write handle: POSIX takes a non-blocking `flock(2)` through the prebuilt `@deepseek-ai/node-addon-system/flock` binding, and Windows holds a named kernel semaphore (count 1) derived from the canonical lock path (`CreateSemaphoreW` in src/win32.ts beside the existing koffi bindings) — a kernel object with no filesystem footprint, destroyed with its last handle. Contention maps to `SessionAlreadyOwnedError`; the kernel releases the lock when the holder's descriptor or handle closes, including on any process death, so a crashed holder never blocks a successor and no expiry bookkeeping exists. A live but wedged holder keeps the lock until its process exits: expropriating a stalled writer was rejected because its resumed appends would tear the log, and on POSIX removing the lock file remains the explicit forfeit for that case. Because a POSIX lock names an inode rather than a path, acquisition verifies the locked inode is still the file at the lock path and retries otherwise. The lock is taken at write-open of an existing artifact and, for a created session, only right before its first materializing write — an unmaterialized session leaves no filesystem footprint, and a handle that acquired the lock keeps it through close even when materialization fails; release never removes the lock file, preserving the stable inode later lockers verify against. The browser worker deployment stubs the flock entry to immediate success: it is single-process, so the in-process write claim already excludes every writer. +`SessionWriteLease` (packages/session/session-persistence-jsonl/src/lease.ts) holds a kernel lock on `session.lock` beside the log for the whole life of a write handle: POSIX takes a non-blocking `flock(2)` through the prebuilt `@deepseek-ai/node-addon-system/flock` binding, and Windows holds a named kernel semaphore (count 1) derived from the canonical lock path (`CreateSemaphoreW` in src/win32.ts beside the existing koffi bindings) — a kernel object with no filesystem footprint, destroyed with its last handle. Contention maps to `SessionAlreadyOwnedError`; the kernel releases the lock when the holder's descriptor or handle closes, including on any process death, so a crashed holder never blocks a successor and no expiry bookkeeping exists. A live but wedged holder keeps the lock until its process exits: expropriating a stalled writer was rejected because its resumed appends would tear the log, and on POSIX removing the lock file remains the explicit forfeit for that case. Because a POSIX lock names an inode rather than a path, acquisition verifies the locked inode is still the file at the lock path and retries otherwise. The lock is taken at write-open of an existing artifact and, for a created session, only right before its first materializing write — an unmaterialized session leaves no filesystem footprint, and a handle that acquired the lock keeps it through close even when materialization fails; release never removes the lock file, preserving the stable inode later lockers verify against. The browser worker deployment stubs the flock entry to immediate success because its in-process write claim excludes every writer. Its `node:fs` replacement still reports BigInt device and inode identity from `FileHandle.stat({ bigint: true })`, matching path `stat` while the path names that file, because the lease retains the inode-replacement check after the stubbed acquisition. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.zh.md b/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.zh.md index 8a79b6d532..b287ab7d6c 100644 --- a/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.zh.md +++ b/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.zh.md @@ -10,7 +10,7 @@ JSONL 后端的写句柄认领只在单个后端实例内部排除第二个写 ## Decision -`SessionWriteLease`(packages/session/session-persistence-jsonl/src/lease.ts)在日志旁的 `session.lock` 上持有内核锁,贯穿写句柄的整个生命期:POSIX 经由预编译 `@deepseek-ai/node-addon-system/flock` 绑定 以非阻塞 `flock(2)` 加锁,Windows 持有由规范锁路径派生的命名内核信号量(计数 1,`CreateSemaphoreW`,实现在 src/win32.ts 既有 koffi 绑定旁)——零文件系统足迹的内核对象,随最后一个句柄关闭而销毁。竞争映射为 `SessionAlreadyOwnedError`;持有者的描述符或句柄关闭时内核释放锁,包括任何形式的进程死亡,因此崩溃的持有者从不阻塞后继者,也不存在任何过期簿记。活着但卡死的持有者保有锁直到其进程退出:剥夺停顿写入方的所有权被否决,因为其复活后的追加会撕坏日志;POSIX 上删除锁文件仍是该场景的显式放弃手段。由于 POSIX 锁指向 inode 而非路径,获取后会校验所锁 inode 仍是锁路径上的文件,否则重试。锁在写打开既有工件时立即获取,新建会话则仅在首次物化写入之前获取——未物化的会话不留任何文件系统足迹,已取得锁的句柄即使物化失败也保有锁直到关闭;释放从不删除锁文件,保住后续加锁者用于校验的稳定 inode。浏览器 worker 部署将 flock 入口存根为立即成功:它是单进程部署,进程内写认领已排除所有写入方。 +`SessionWriteLease`(packages/session/session-persistence-jsonl/src/lease.ts)在日志旁的 `session.lock` 上持有内核锁,贯穿写句柄的整个生命期:POSIX 经由预编译 `@deepseek-ai/node-addon-system/flock` 绑定 以非阻塞 `flock(2)` 加锁,Windows 持有由规范锁路径派生的命名内核信号量(计数 1,`CreateSemaphoreW`,实现在 src/win32.ts 既有 koffi 绑定旁)——零文件系统足迹的内核对象,随最后一个句柄关闭而销毁。竞争映射为 `SessionAlreadyOwnedError`;持有者的描述符或句柄关闭时内核释放锁,包括任何形式的进程死亡,因此崩溃的持有者从不阻塞后继者,也不存在任何过期簿记。活着但卡死的持有者保有锁直到其进程退出:剥夺停顿写入方的所有权被否决,因为其复活后的追加会撕坏日志;POSIX 上删除锁文件仍是该场景的显式放弃手段。由于 POSIX 锁指向 inode 而非路径,获取后会校验所锁 inode 仍是锁路径上的文件,否则重试。锁在写打开既有工件时立即获取,新建会话则仅在首次物化写入之前获取——未物化的会话不留任何文件系统足迹,已取得锁的句柄即使物化失败也保有锁直到关闭;释放从不删除锁文件,保住后续加锁者用于校验的稳定 inode。浏览器 worker 部署将 flock 入口存根为立即成功,因为进程内写认领已排除所有写入方。它的 `node:fs` 替代实现仍从 `FileHandle.stat({ bigint: true })` 报告 BigInt device 与 inode 身份,并在该路径仍指向所打开文件时与路径 `stat` 一致,因为租约在存根式加锁后仍保留 inode 替换检查。 ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.i18n.yaml b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.i18n.yaml index f9094ecb23..2a65513f7d 100644 --- a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-04-right-sidebar-docking-infrastructure.md -2026-09-04-right-sidebar-docking-infrastructure.md: 3600a17fdd0659c4f235922fa1bc905f03f5841e -2026-09-04-right-sidebar-docking-infrastructure.zh.md: 2a9ddfb7ec1fa34cefe434692829939767a6b710 +2026-09-04-right-sidebar-docking-infrastructure.md: 936f73c9210baf4297eace2deb270bb9594d7e8d +2026-09-04-right-sidebar-docking-infrastructure.zh.md: d234dbcbb2f60ba1dd3b3d9fca2936a16521751d diff --git a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md index 3600a17fdd..936f73c921 100644 --- a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md +++ b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md @@ -37,9 +37,9 @@ The right Sidebar uses one mounted content tree in normal and fullscreen modes; ### State -[Default pages and close protection](2026-09-08-sidebar-default-pages.md) supersedes explicit last-tab closing and default-guide reseeding here; moving tabs still settles emptied panes. +[Default pages](2026-09-08-sidebar-default-pages.md) supersede default-guide reseeding here; [last-tab close rules](2026-09-08-sidebar-last-tab-close-rules.md) own explicit closing, while moving tabs still settles emptied panes. -`ui-sidebar-right` keeps one `SurfaceState` per session id — the layout, its history, and the mint counter — in a store declared at the seat registration. Every action mints the ids its intent needs, asks a kit planner for the operations, runs the settle planner over the result, and records the whole intent as one history entry before assigning the session's surface back; no action edits a layout in place. The settle step is the product's rule: a docked pane whose last tab is closed, moved out, or floated is merged away, and when only the root pane remains and it is empty, the guide tab is reseeded — there is always at least one tab and never an empty pane, so no pane-closing gesture exists. State is memory-only: a reload returns every session to the collapsed default, and switching sessions keeps each surface where it was. Layout is presentation state and never enters the session log. +`ui-sidebar-right` keeps one `SurfaceState` per session id — the layout, its history, and the mint counter — in a store declared at the seat registration. Every action mints the ids its intent needs, asks a kit planner for the operations, runs the settle planner over the result, and records the whole intent as one history entry before assigning the session's surface back; no action edits a layout in place. The settle step is the product's rule: a docked pane whose last tab is closed, moved out, or floated is merged away, and an expanded empty root pane receives the current default page. A collapsed surface may remain empty until its next expansion; no separate pane-closing gesture exists. State is memory-only: a reload returns every session to the collapsed default, and switching sessions keeps each surface where it was. Layout is presentation state and never enters the session log. ### Beyond the surface @@ -69,7 +69,7 @@ The surface renders tabs whose bodies it does not know: each tab carries a `kind **Undo and redo buttons on the panel header.** Shipped first, then removed: the sequence is an architectural fact, and stepping it is not a product action yet. The API stays reachable as `@internal` methods for tests and the future navigation controller. -**Empty panes as a persistent state.** The first design allowed a pane to stay after its last tab left, with a placeholder. Rejected because nothing offered a way to close such a pane; every intent now settles the surface so an emptied pane is merged away and an emptied root pane reseeds the guide. +**Empty panes as a persistent state.** The first design allowed a pane to stay after its last tab left, with a placeholder. Rejected because nothing offered a way to close such a pane; every intent settles the surface so an emptied side pane is merged away. An empty root receives the current default page only while the column is expanded. **Inline the kit through `packages/util` and the `INLINE_SAFE` list.** A build probe showed it works, but the util build chain has no CSS pipeline and the kit ships a stylesheet; the static-linked client package (the `ui-primitives` precedent) was chosen knowing that changing the kit means rebuilding the shell and reloading. @@ -77,7 +77,7 @@ The surface renders tabs whose bodies it does not know: each tab carries a `kind - The docking surface itself no longer overflows its panel: `.surface` and `.pane` clamp to the column (`min-width: 0`, `overflow: hidden`), so a long unwrapped line scrolls inside the body and the strip's controls stay in view in every split. - Layout is undoable and per session, and it is memory-only; a reload starts every session collapsed. Undo is reachable only through `@internal` service methods; the product shows no history controls. -- A pane cannot be left empty and the surface cannot be left tabless: closing, moving out, or floating a pane's last tab drops the pane, and emptying the last pane brings the guide back. +- An expanded surface has no empty panes. Empty side panes merge away; an empty root receives the current default page only while expanded. New sessions and a collapsed surface whose last tab closed remain empty until expansion. - A pane holds at most one guide tab: a second one cannot be added, opened, duplicated, or moved in; the guide's uniqueness is per pane, so a split still seeds its new pane with a guide. - A pane may split only when each equal half can still hold what cannot shrink: the strip's fixed controls (its width minus the chip box and the fill, so the top-right pane's chrome counts on the half that hosts it) plus one chip at its minimum, measured in the component layer after every commit and on resize. Otherwise the split control stays, disabled with its own copy, the matching edge drop zones are withheld, and panes the user narrows keep their size; the product permits at most two horizontal panes, regardless of widening or divider movement. - The Sidebar panel never moves when the presentation switches, and its slide is the same in both presentations; the conversation is the only thing that animates on a switch. A hidden panel keeps its tabs mounted, so a preview survives a collapse. diff --git a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md index 2a9ddfb7ec..d234dbcbb2 100644 --- a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md +++ b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md @@ -37,9 +37,9 @@ Agent 产出的文件是最尖锐的案例。产出文件 chip 或 `read` 行的 ### 状态 -[默认页与关闭保护](2026-09-08-sidebar-default-pages.zh.md)取代此处的显式关闭最后一个 tab 和默认补入引导页;移动 tab 仍会处理被清空的格。 +[默认页](2026-09-08-sidebar-default-pages.zh.md)取代此处的默认补入引导页;[最后一个 tab 的关闭规则](2026-09-08-sidebar-last-tab-close-rules.zh.md)负责显式关闭,移动 tab 仍会处理被清空的格。 -`ui-sidebar-right` 为每个会话 id 保存一份 `SurfaceState`——布局、历史与铸造计数——住在坑位注册时声明的 store 里。每个 action 先铸造意图所需的 id,向库的 planner 索取操作,对结果跑一遍 settle planner,把整个意图记为一条历史账,再把该会话的 surface 整体赋回;没有 action 就地改布局。settle 是产品规则:最后一个 tab 被关闭、拖走或悬浮出去的停靠 pane 会被合并掉;只剩根 pane 且为空时重新种上引导 tab——永远至少有一个 tab、永远没有空 pane,所以不存在"关闭 pane"手势。状态仅在内存:刷新使所有会话回到折叠默认态,切换会话时各 surface 保持原样。布局是呈现状态,永不进入会话日志。 +`ui-sidebar-right` 为每个会话 id 保存一份 `SurfaceState`——布局、历史与铸造计数——住在坑位注册时声明的 store 里。每个 action 先铸造意图所需的 id,向库的 planner 索取操作,对结果跑一遍 settle planner,把整个意图记为一条历史账,再把该会话的 surface 整体赋回;没有 action 就地改布局。settle 是产品规则:最后一个 tab 被关闭、拖走或悬浮出去的停靠 pane 会被合并掉;展开且为空的根 pane 会填入当前默认页。折叠的布局可以保持为空,直到下次展开;没有单独的关闭 pane 手势。状态仅在内存:刷新使所有会话回到折叠默认态,切换会话时各 surface 保持原样。布局是呈现状态,永不进入会话日志。 ### 面之外 @@ -69,7 +69,7 @@ Agent 产出的文件是最尖锐的案例。产出文件 chip 或 `read` 行的 **面板头部的 undo 与 redo 按钮。** 先上后撤:序列是架构事实,步进它现在还不是产品动作。API 以 `@internal` 方法保留给测试与将来的导航控制器。 -**空 pane 作为一种持久状态。** 第一版允许 pane 在最后一个 tab 离开后带占位留下。否决,因为没有任何方式关掉这样的 pane;现在每个意图都会整理 surface,被清空的 pane 合并掉,被清空的根 pane 重新种上引导。 +**空 pane 作为一种持久状态。** 第一版允许 pane 在最后一个 tab 离开后带占位留下。否决,因为没有任何方式关掉这样的 pane;每个意图都会整理 surface,被清空的侧 pane 合并掉。空根 pane 只在该列展开时填入当前默认页。 **经 `packages/util` 与 `INLINE_SAFE` 清单内联库。** 构建探针证明可行,但 util 构建链没有 CSS 管线而库带样式表;在知晓改库须重建壳并刷新页面的前提下,选择静态链接的 client 包(`ui-primitives` 先例)。 @@ -77,7 +77,7 @@ Agent 产出的文件是最尖锐的案例。产出文件 chip 或 `read` 行的 - 停靠面自身不再溢出面板:`.surface` 与 `.pane` 收在列内(`min-width: 0`、`overflow: hidden`),长的不换行行在正文内滚动,tab 条控件在任何分栏下都可见。 - 布局可撤销且按会话隔离,同时仅在内存;刷新使所有会话回到折叠态。undo 只能经 `@internal` 服务方法触达;产品不显示历史控件。 -- pane 不能留空、surface 不能没有 tab:关闭、拖走或悬浮出 pane 的最后一个 tab 会删掉该 pane,清空最后一个 pane 会让引导回来。 +- 展开的布局不保留空 pane。空侧 pane 被合并,空根 pane 只在展开时填入当前默认页。新会话以及关闭最后一个 tab 后收起的布局保持为空,直到下次展开。 - 一个 pane 最多持有一个引导 tab:第二个不能被添加、打开、复制或搬入;唯一性按 pane 算,所以分栏仍给新 pane 种引导。 - pane 只有在等分后的两半都仍能容下不可收缩部分时才可分栏:tab 条的固定控件(条宽减去 chip 盒与填充,因此右上 pane 的面板控件只计在承载它的那一半)加一个最小宽度的 chip,由组件层在每次提交与尺寸变化后测量。否则分栏控件保留但禁用并带自己的文案,对应的边缘落区不再提供,用户拖窄的 pane 保持原尺寸;产品最多两个水平窗格,不因拉宽或拖分隔条而提高上限。 - 切换呈现模式时 Sidebar 面板一动不动,两种模式的平移一模一样;切换时只有会话区在动。隐藏的面板保持 tab 挂载,预览在折叠后仍在。 diff --git a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.i18n.yaml b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.i18n.yaml index 4aec891a9f..ddf0632164 100644 --- a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-04-web-clickable-link-styles.md -2026-09-04-web-clickable-link-styles.md: 8781990a40de30e26d0c59c0ecc1a08ba41643ac -2026-09-04-web-clickable-link-styles.zh.md: 7b5fde4c87319f6ae34b76d1c8e712e6f9f18392 +2026-09-04-web-clickable-link-styles.md: 6fa61945143853a2812468638dafd23ebcd456dc +2026-09-04-web-clickable-link-styles.zh.md: 0b15a6c72df9151ffb79f7fb41d582e191e79be6 diff --git a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md index 8781990a40..6fa6194514 100644 --- a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md +++ b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md @@ -13,7 +13,7 @@ Clickable artifact links in the chat transcript wore four different costumes: ma One link language across the transcript's clickable-link surfaces — markdown anchors (including reference links, mailto, and URL-promoted inline code), prose file mentions, web search source links and the fetch URL, produced-file chips, and workflow member links: - Color comes through a dedicated `--dsw-alias-link` alias in `design-platform.css` (light `deepseek-500`, dark `deepseek-400`), decoupled from `state-business-primary`; links render at `font-weight: 500` with no underline at rest and `underline dotted` at 3px offset on hover/focus. -- A leading category glyph — the new `LinkIcon` in ui-primitives with kinds `url` (globe), `folder`, `code`, `image`, `document`, and `other` (paper) — renders `currentColor` only; `classifyLinkPath` derives the file kinds from the extension, and code, web, and data extensions share the code glyph by design. Two anchor shapes carry no glyph: workflow member links (an in-app member view fits no file or URL category) and anchors wrapping only images (a badge or thumbnail — a dangling globe beside the picture leads no text). Inline glyphs sit at 1.1em with a −0.25em baseline offset; the flex-centered produced-file glyphs instead nudge 1.2px down because the 22px text box carries its glyphs below box center. +- A leading category glyph — the new `LinkIcon` in ui-primitives with kinds `url` (globe), `folder`, `code`, `image`, `document`, and `other` (paper) — renders `currentColor` only; `classifyLinkPath` folds the [shared detailed file-type classification](2026-09-08-shared-file-type-icons.md) into those six link categories, and code, web, and data extensions share the code glyph by design. Two anchor shapes carry no glyph: workflow member links (an in-app member view fits no file or URL category) and anchors wrapping only images (a badge or thumbnail — a dangling globe beside the picture leads no text). Inline glyphs sit at 1.1em with a −0.25em baseline offset; the flex-centered produced-file glyphs instead nudge 1.2px down because the 22px text box carries its glyphs below box center. - Produced-file chips drop the grey pill and the 96px cap: plain link-blue text at natural width that shrinks with ellipsis only when the row overflows; the container-query bands still budget 96px per chip when choosing how many chips to show. - Deliberately untouched: ToolRow's grey dotted file links, and the grey "Show in folder" action (it gains the folder glyph but keeps its grey style). - In the same pass, the inline-code chip tint moved from `neutral-bluish-100` to `neutral-50` (dark: `neutral-800`) and gained a 0.5px l1 border. @@ -22,8 +22,8 @@ Coverage: a LinkIcon unit spec (one distinct glyph per kind, classification tabl ## Alternatives considered -- **Colored Word/Excel/PPT/PDF brand glyphs.** Implemented, then removed: fixed brand fills break the icon set's currentColor-only rule, so those extensions fold into the single outline `document` glyph. -- **Per-extension icons.** Collapsed to six categories: more glyphs than the eye can parse at 14px adds noise, and per-site favicons remain possible later behind the same `url` category. +- **Colored Word/Excel/PPT/PDF link glyphs.** Rejected: fixed brand fills break the icon set's currentColor-only rule, so those extensions fold into the single outline `document` glyph. The larger file-card primitive uses distinct current-color silhouettes instead. +- **Per-extension link icons.** Collapsed to six categories: more glyphs than the eye can parse at 14px adds noise. The 28px `FileTypeIcon` owns the more detailed file identities, and per-site favicons remain possible later behind the same `url` category. - **Keeping links on `state-business-primary`.** Darker link blues (blue-600/650/700 were auditioned and reverted) would have dragged focus rings and state dots along; the dedicated alias localizes any future tuning to one line. - **Glyphs on ToolRow path links.** Rejected: tool rows keep their quieter grey dotted affordance, and leading glyphs there would stack icons in already dense rows. diff --git a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md index 7b5fde4c87..0b15a6c72d 100644 --- a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md +++ b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md @@ -13,7 +13,7 @@ Status: implemented 会话记录的可点击链接表面——Markdown 锚点(含引用式链接、mailto、被提升为链接的 inline code)、正文文件引用、网页搜索来源链接与抓取 URL、产物 chips、workflow 成员链接——统一为一套链接语言: - 颜色经由 `design-platform.css` 中专用的 `--dsw-alias-link` 别名(亮色 `deepseek-500`,暗色 `deepseek-400`),与 `state-business-primary` 解耦;链接以 `font-weight: 500` 呈现,默认无下划线,hover/focus 时为 3px offset 的 `underline dotted`。 -- 前置分类图标——ui-primitives 新增的 `LinkIcon`,kind 为 `url`(地球)、`folder`、`code`、`image`、`document`、`other`(纸张)——只渲染 `currentColor`;`classifyLinkPath` 按扩展名推导文件类别,代码、网页、数据扩展名按设计共用 code 图形。两类锚点不带图标:workflow 成员链接(应用内成员视图不属于任何文件或 URL 类别)和只包图片的锚点(徽章或缩略图——图片旁悬着的地球没有可引导的文字)。行内图标为 1.1em、基线偏移 −0.25em;flex 居中的产物图标则下移 1.2px,因为 22px 文字盒的字形低于盒中心。 +- 前置分类图标——ui-primitives 新增的 `LinkIcon`,kind 为 `url`(地球)、`folder`、`code`、`image`、`document`、`other`(纸张)——只渲染 `currentColor`;`classifyLinkPath` 把[共享精细文件类型分类](2026-09-08-shared-file-type-icons.zh.md)折叠进这六种链接类别,代码、网页、数据扩展名按设计共用 code 图形。两类锚点不带图标:workflow 成员链接(应用内成员视图不属于任何文件或 URL 类别)和只包图片的锚点(徽章或缩略图——图片旁悬着的地球没有可引导的文字)。行内图标为 1.1em、基线偏移 −0.25em;flex 居中的产物图标则下移 1.2px,因为 22px 文字盒的字形低于盒中心。 - 产物 chips 去掉灰色药丸和 96px 上限:纯链接蓝文字按自然宽度展示,仅当整行溢出时才收缩出省略号;容器查询档位在决定展示几个 chip 时仍按每个 96px 预算。 - 刻意不动:ToolRow 的灰色点线文件链接,以及灰色的「在文件夹中显示」操作(它获得文件夹图标但保持灰色样式)。 - 同一批次中,inline code 底色从 `neutral-bluish-100` 换到 `neutral-50`(暗色:`neutral-800`),并新增 0.5px l1 描边。 @@ -22,8 +22,8 @@ Status: implemented ## 备选方案 -- **彩色 Word/Excel/PPT/PDF 品牌图形。** 实现后又移除:固定品牌填充违反图标集 currentColor-only 规则,这些扩展名并入单一的 outline `document` 图形。 -- **每个扩展名一个图标。** 收敛为六个类别:14px 下超出肉眼可分辨数量的图形只会增加噪音,按站点的 favicon 以后仍可在同一 `url` 类别之下引入。 +- **彩色 Word/Excel/PPT/PDF 链接图形。** 否决:固定品牌填充违反图标集 currentColor-only 规则,因此这些扩展名并入单一的 outline `document` 图形。较大的文件卡片 primitive 改用各自不同的 current-color 轮廓。 +- **每个扩展名一个链接图标。** 收敛为六个类别:14px 下超出肉眼可分辨数量的图形只会增加噪音。28px 的 `FileTypeIcon` 拥有更精细的文件身份,按站点的 favicon 以后仍可在同一 `url` 类别之下引入。 - **链接继续用 `state-business-primary`。** 更深的链接蓝(试过 blue-600/650/700 又回退)会连带焦点环和状态点;专用别名把未来的调色收敛到一行。 - **给 ToolRow 路径链接加图形。** 否决:工具行保持更安静的灰色点线示能,在已经很密的行里加前置图形会造成图标堆叠。 diff --git a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml index 831ed01531..5c04c6fdb7 100644 --- a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-05-sidebar-text-preview-and-file-tree.md -2026-09-05-sidebar-text-preview-and-file-tree.md: 1a743c35634c5772e8173b770228a7f736a9d710 -2026-09-05-sidebar-text-preview-and-file-tree.zh.md: 72253286862f1e8729683fb719e83cfcf544d8e8 +2026-09-05-sidebar-text-preview-and-file-tree.md: e25e1c55f5434f2b3b1b2703f886cf3c1e9fe80c +2026-09-05-sidebar-text-preview-and-file-tree.zh.md: f313d30fa68f9d8afba84a470e5e80902b13d1e3 diff --git a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md index 1a743c3563..e25e1c55f5 100644 --- a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md +++ b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md @@ -24,7 +24,7 @@ The body is a centred column — a lead line (`侧栏用来放你想一直看着 The body is also the replacement seam. It renders the `sidebar.right.tab.guide` chain with the shipped guide as the chain's fallback, so a product that registers its own entry takes the whole body, and with no entry, or every entry declining, the shipped guide draws. Because the shipped guide is the fallback and not a chain entry, there is always exactly one body and it cannot be outvoted by accident. -A pane holds at most one guide, and the docking layer enforces it as product behaviour: the strip's add control hides while a guide is present, opening the guide into such a pane focuses it, a guide is never duplicated, and a guide dragged, dropped, or docked into a pane that already has one merges into it (the arriving tab closes). Settling a surface reseeds the guide when the root pane empties, so there is always at least one tab and never an empty pane. +A pane holds at most one guide, and the docking layer enforces it as product behaviour: the strip's add control hides while a guide is present, opening the guide into such a pane focuses it, a guide is never duplicated, and a guide dragged, dropped, or docked into a pane that already has one merges into it (the arriving tab closes). An expanded empty root receives the current default page. Collapsed layouts may remain empty until expansion, when the default-page selection runs. ### The text preview @@ -108,12 +108,12 @@ Copy is the `sidebarFiles` namespace, thirteen keys. Row states: `loading` 「 ## Testing -The text preview's `tests/` cover the registry claim and yielding (through the real `SidebarRightTabRegistry`), the address translation (`sessionFileOf` accepting the `session` scope and throwing on others), the store's page, version, reset, view, and forget actions, the face's in-flight, failure, aborted, and reload paths, the page arithmetic (`linesOf`, `offsetsOf`, `lastLineLoaded`), the body's first read, load-more, retry, change bar, navigation walk, jump-once, remount, wrap default and toggle, header controls, and forget-on-abort, the failure-line mapping, and the plugin's registrations and their removal on dispose. A Chromium probe against the built app recorded the fill and scroll numbers (`.artifacts/sidebar-tab-types/app-probe.log`, `ROUND3`): a short file's preview is the pane body's content height, a long file scrolls inside the preview body, and the pane body never scrolls. The file tree's `tests/` cover ordering, lazy loading, collapse memory, reload, the three entry types, truncation and failure rows, and forget-on-abort. `apps/web/tests/sidebar-right.e2e.ts` opens a produced file from the conversation into the preview over the real Remote carrier. +The text preview's `tests/` cover the registry claim and yielding (through the real `SidebarRightTabRegistry`), the address translation (`sessionFileOf` accepting the `session` scope and throwing on others), the store's page, version, reset, view, and forget actions, the face's in-flight, failure, aborted, and reload paths, the page arithmetic (`linesOf`, `offsetsOf`, `lastLineLoaded`), the body's first read, load-more, retry, change bar, navigation walk, jump-once, remount, wrap default and toggle, header controls, and forget-on-abort, the failure-line mapping, and the plugin's registrations and their removal on dispose. A Chromium probe against the built app recorded the fill and scroll numbers (`.artifacts/sidebar-tab-types/app-probe.log`, `ROUND3`): a short file's preview is the pane body's content height, a long file scrolls inside the preview body, and the pane body never scrolls. The file tree's `tests/` cover ordering, lazy loading, collapse memory, reload, the three entry types, truncation and failure rows, and forget-on-abort. `apps/web/tests/sidebar-right.e2e.ts` opens a produced file from the conversation into the preview over the real Remote carrier. `apps/web/tests/document-preview.e2e.ts` covers centred intrinsic-size images, two-axis image scrolling, and inert SVG scripts. ## Deferred - Virtualized or seekable page loading (pages load in order), a reload that restores the loaded range, throttled scroll persistence, and a wrap icon in `ui-primitives`. -- Images, search, a total line count, and an end-of-file marker. +- Search, a total line count, and an end-of-file marker. - Search, an artifact filter, drag-and-drop, rename, a context menu, current-file highlight, filesystem watching, and browsing above the workspace root in the file tree. - Product review of the guide's copy, and the guide's behaviour when a type contributes several entries. diff --git a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md index 7225328686..f313d30fa6 100644 --- a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md +++ b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md @@ -24,7 +24,7 @@ Sidebar 随包交付三个 tab 类型:**引导页**(`ui-sidebar-right`)、 体同时也是替换接缝。它渲染 `sidebar.right.tab.guide` 链,并以随包交付的引导页作为链的 fallback,于是注册了自己入口的产品接管整个体,而没有入口、或每个入口都拒绝时,随包交付的引导页照常绘制。因为随包交付的引导页是 fallback 而不是链上的一个入口,所以永远恰有一个体,也不可能被意外投掉。 -一个 pane 最多持有一个引导页,停靠层把这条作为产品行为强制执行:有引导页时 tab 条的添加控件隐藏,往这样的 pane 打开引导页只是聚焦它,引导页永不复制,被拖拽、落下或回坞进已有引导页的 pane 的引导页并入它(来者关闭)。settle 一个 surface 时,根 pane 空了就重新播下引导页,于是永远至少有一个 tab、永远没有空 pane。 +一个 pane 最多持有一个引导页,停靠层把这条作为产品行为强制执行:有引导页时 tab 条的添加控件隐藏,往这样的 pane 打开引导页只是聚焦它,引导页永不复制,被拖拽、落下或回坞进已有引导页的 pane 的引导页并入它(来者关闭)。展开且为空的根 pane 会填入当前默认页。折叠的布局可以保持为空,展开时才选择并创建默认页。 ### 文本预览 @@ -108,12 +108,12 @@ face 是树唯一的异步半边。`start(tabId, root, signal)` 以根展开态 ## Testing -文本预览的 `tests/` 覆盖:注册表认领与让位(经真实的 `SidebarRightTabRegistry`)、地址翻译(`sessionFileOf` 接受 `session` 作用域、其他一律抛错)、store 的页、版本、reset、视图与 forget 各 action、face 的进行中、失败、abort 与重载路径、页算术(`linesOf`、`offsetsOf`、`lastLineLoaded`)、体的首读、加载更多、重试、变更提示条、导航补页、只跳一次、重新挂载、换行默认与切换、头部控件与 abort 即忘、失败行映射,以及插件的各项注册与 dispose 时的撤销。针对已构建应用的 Chromium 探针记录了撑满与滚动的数字(`.artifacts/sidebar-tab-types/app-probe.log`,`ROUND3`):短文件的预览高度等于 pane 体内容区高度,长文件在预览体内滚动,pane 体从不滚动。文件树的 `tests/` 覆盖排序、懒加载、折叠记忆、重新读取、三种条目类型、截断与失败行,以及 abort 即忘。`apps/web/tests/sidebar-right.e2e.ts` 经真实 Remote 载体把会话里的产物文件打开进预览。 +文本预览的 `tests/` 覆盖:注册表认领与让位(经真实的 `SidebarRightTabRegistry`)、地址翻译(`sessionFileOf` 接受 `session` 作用域、其他一律抛错)、store 的页、版本、reset、视图与 forget 各 action、face 的进行中、失败、abort 与重载路径、页算术(`linesOf`、`offsetsOf`、`lastLineLoaded`)、体的首读、加载更多、重试、变更提示条、导航补页、只跳一次、重新挂载、换行默认与切换、头部控件与 abort 即忘、失败行映射,以及插件的各项注册与 dispose 时的撤销。针对已构建应用的 Chromium 探针记录了撑满与滚动的数字(`.artifacts/sidebar-tab-types/app-probe.log`,`ROUND3`):短文件的预览高度等于 pane 体内容区高度,长文件在预览体内滚动,pane 体从不滚动。文件树的 `tests/` 覆盖排序、懒加载、折叠记忆、重新读取、三种条目类型、截断与失败行,以及 abort 即忘。`apps/web/tests/sidebar-right.e2e.ts` 经真实 Remote 载体把会话里的产物文件打开进预览。`apps/web/tests/document-preview.e2e.ts` 覆盖居中的固有尺寸图片、双轴图片滚动和不可执行的 SVG 脚本。 ## Deferred - 虚拟化或可 seek 的分页加载(页按顺序加载)、恢复已加载范围的重新载入、节流的滚动位置持久化,以及 `ui-primitives` 里的换行图标。 -- 图片、搜索、总行数与文件末尾标记。 +- 搜索、总行数与文件末尾标记。 - 文件树的搜索、产物过滤、拖拽、重命名、右键菜单、高亮当前文件、文件系统监听,以及浏览到工作区根之上。 - 引导页文案的产品评审,以及一个类型贡献多个入口时引导页的行为。 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..9d3acb0e07 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: 650c16fe5a614ce1ccf118744a5f0e8cb5d19f4b +2026-09-08-present-workspace-source-files.zh.md: 61bca82c83ff42600a7017039a6be4c3a4605769 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..650c16fe5a 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 @@ -10,11 +10,15 @@ Users need to open and edit the files produced in their workspace, including she ## Decision -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 [present tool](../../../../packages/fs/tool-present/README.md) declares existing regular source files under the [Session filesystem access policy](2026-09-09-present-filesystem-access.md). 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 verifies regular-file existence and Host-path mapping 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. + +File cards use the same split-control pattern as the Session header. The card and the left Open segment preview the source in the right Sidebar; the chevron opens the standard menu for default-app and file-manager actions. The Host selects the file in Finder or Explorer, or opens its containing folder through the default Linux file manager. Both native actions resolve the same saved declaration and verify the Session filesystem and Host path; neither accepts a browser-supplied replacement path. Host-derived desktop metadata keeps remote-browser labels and availability honest, and the route enforces the configured availability on each native gesture. One delivery spans the row; multiple deliveries use at most two columns, retain every declaration, and collapse after the first four cards until the user expands the list. Desktop metadata is invalidated with the connection generation so an old Host cannot keep native actions disabled or supply the wrong file-manager labels. Old metadata requests are cancelled and cannot replace the new generation’s response. ## Alternatives considered @@ -26,12 +30,12 @@ An authenticated POST selects a declaration by viewed Session, event sequence, a **Tool text as the durable index** cannot survive post-processing or result spill reliably. Execution identity and final successful results retain declaration ownership independently of displayed tool text. -**Descriptor-bound filesystem extensions** would change every provider without making an external desktop application's later path lookup atomic. Current checks reject ordinary escapes; concurrent swap-and-restore remains outside the path API's guarantees. +**Descriptor-bound filesystem extensions** would change every provider without making an external desktop application's later path lookup atomic. Current checks verify file metadata and path mapping; concurrent swap-and-restore remains outside the path API's guarantees. ## Consequences The Session log persists declarations but no attachment references or file contents from `present`. Session ZIP exports contain these declarations; transferring the log does not transfer workspace files. The event remains required-on-read because silently losing delivery declarations would alter reconstructed or forked history. Released Session format generations remain unchanged. -The removed file-size cap has no role in a metadata-only declaration; the configurable file-count limit still bounds result size. Cards show file names, types, and descriptions without stale byte-size metadata. No artifact service or speculative storage fallback is introduced. +The removed file-size cap has no role in a metadata-only declaration; the configurable file-count limit still bounds result size. Cards show file names and descriptions, falling back to file types, without stale byte-size metadata. No artifact service or speculative storage fallback is introduced. -Focused tests cover content-free declarations, invalid inputs, blocked results, source-path identity, current bytes after edits, missing files, workspace escapes, fork-relative paths, retry, cancellation, and disposal. The recorded Web scenario covers nested completion followed by enclosing failure, source edits, reload, deletion errors, card and prose opens without browser downloads, and content-free Session export. +Focused tests cover content-free declarations, invalid inputs, blocked results, source-path identity, current bytes after edits, missing files, external paths and unavailable Host mappings, fork-relative paths, retry, cancellation, and disposal. The recorded Web scenario covers nested completion followed by enclosing failure, source edits, reload, deletion errors, card and prose opens without browser downloads, and content-free Session export. 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..61bca82c83 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 @@ -10,11 +10,15 @@ Status: implemented ## 决策 -[present 工具](../../../../packages/fs/tool-present/README.zh.md)声明交付调用方 Session 工作区中已存在的普通文件。它记录路径和可选说明,不读取或复制内容。[交付插件](../../../../packages/client/ui-deliverables/README.zh.md)使用 Host 默认应用打开当前工作区源文件。下次打开会看到编辑后的内容;删除或移动文件会使声明不可用。文件内容保留与写时复制存储延期到有持久化设计负责时实现。 +[present 工具](../../../../packages/fs/tool-present/README.zh.md)声明交付[Session 文件系统访问策略](2026-09-09-present-filesystem-access.zh.md)允许的已有普通源文件。它记录路径和可选说明,不读取或复制内容。[交付插件](../../../../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 在原生打开前检查普通文件是否存在,并验证 Host 路径映射。路由释放时取消并等待进行中的命令。“本轮文件改动”行列出成功的文件工具修改,并保留独立的文本预览行为。其英文标签为“Files changed”;两个标签均不表示最终交付。 + +文件卡片采用与 Session 顶栏相同的分段控件。点击卡片或左侧“打开”区域会在右侧 Sidebar 预览源文件;右侧箭头打开包含默认应用与文件管理器操作的标准菜单。Host 在 Finder 或文件资源管理器中选中文件,或通过 Linux 默认文件管理器打开所在文件夹。两个原生操作都解析同一份已保存声明并验证 Session 文件系统与 Host 路径;均不接受浏览器提供的替代路径。来自 Host 的桌面信息使远程浏览器中的文案和可用性保持准确,路由在每次原生操作时执行配置的可用性检查。单个交付占满整行;多个交付每行最多两列,并保留所有声明,前四张卡片之后的内容在用户展开列表前保持收起。 桌面元数据随连接代次失效,避免旧主机信息让原生操作持续禁用或显示错误的文件管理器名称。旧元数据请求会被取消,不能覆盖新代次的响应。 ## 考虑过的替代方案 @@ -26,12 +30,12 @@ Status: implemented **以工具文本作为持久索引**无法可靠应对后处理或结果溢出。执行身份与最终成功结果使声明归属独立于展示的工具文本。 -**绑定文件描述符的文件系统扩展**会改动所有提供方,却无法使外部桌面应用随后按路径打开的动作原子化。当前检查拒绝普通越界;并发替换后复原仍不在路径 API 的保证范围内。 +**绑定文件描述符的文件系统扩展**会改动所有提供方,却无法使外部桌面应用随后按路径打开的动作原子化。当前检查验证文件元数据和路径映射;并发替换后复原仍不在路径 API 的保证范围内。 ## 影响 Session 日志持久化声明,不保存来自 `present` 的附件引用或文件内容。Session ZIP 导出包含这些声明;转移日志不会转移工作区文件。该事件仍要求读取端识别,因为静默丢失交付声明会改变重建或 fork 的历史。已发布 Session 格式代际保持不变。 -仅声明元数据不需要文件大小上限,因此删除该限制;可配置的文件数量上限仍限制结果大小。卡片展示文件名称、类型和说明,不展示可能过时的字节大小。不引入 artifact 服务或推测性的存储回退。 +仅声明元数据不需要文件大小上限,因此删除该限制;可配置的文件数量上限仍限制结果大小。卡片展示文件名称与说明,没有说明时回退到文件类型,并且不展示可能过时的字节大小。不引入 artifact 服务或推测性的存储回退。 定向测试覆盖不读取内容的声明、无效输入、被阻止的结果、源路径身份、编辑后的当前字节、缺失文件、工作区越界、fork 相对路径、重试、取消与释放。录制的 Web 场景覆盖嵌套成功后外层失败、源文件编辑、重新加载、删除错误、卡片与正文打开且无浏览器下载,以及不包含交付内容的 Session 导出。 diff --git a/.agents/notes/implemented/process/2026-09-08-comment-only-review-routing.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml similarity index 57% rename from .agents/notes/implemented/process/2026-09-08-comment-only-review-routing.i18n.yaml rename to .agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml index b32260c5e7..bf23295de5 100644 --- a/.agents/notes/implemented/process/2026-09-08-comment-only-review-routing.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-08-comment-only-review-routing.md -2026-09-08-comment-only-review-routing.md: 050905285b2291b34da9873d19c2f122c088a9e5 -2026-09-08-comment-only-review-routing.zh.md: b98f5d70b4d5c0fd27df1c393238b0802e420e09 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md +2026-09-08-shared-file-type-icons.md: f8c87cb3259f757483a6658d79306d3f02d90488 +2026-09-08-shared-file-type-icons.zh.md: ea115cd63b3144ded2c99f818bb9364eea8adfef diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md new file mode 100644 index 0000000000..f8c87cb325 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md @@ -0,0 +1,43 @@ +# Agent Note: Shared file-type icons + +Status: implemented + +English | [中文](2026-09-08-shared-file-type-icons.zh.md) + +## Problem + +Client feature plugins can share React components only through `@deepseek-ai/dsh-client-ui-primitives`, but file cards had no shared file-type presentation. `LinkIcon` owned the only extension table and deliberately collapsed paths into six link categories, while attachment cards, sent-message attachments, queued files, and workspace file rows used one generic document glyph. Two of those consumers also carried separate extension-display helpers. Adding a precise file icon anywhere else would have required another extension table or a runtime import between feature plugins. + +## Decision + +`ui-primitives` owns one Cordis-free file classification and rendering API. `fileExtension(path)` applies the shared basename and final-dot semantics to either path separator. `classifyFileType(path)` matches without case sensitivity and returns the closed `FileType` union: the traditional `code`, `excel`, `folder`, `html`, `image`, `markdown`, `other`, `pdf`, `ppt`, `video`, and `word` categories plus the detailed `CodeFileType` categories rendered by `CodeFileIcon`. Exact filenames, filename prefixes, filename suffixes, optional project context, and extensions run in that order. The path classifier returns every member except `folder`; callers use the explicit `FileTypeIcon` `kind` override when they know an entry is a directory. Unknown extensions, missing extensions, and trailing dots resolve to `other`, except the shared table recognizes named files such as `Dockerfile`, `Makefile`, `package.json`, `.gitignore`, `README`, and `CHANGELOG`. + +`FileTypeIcon` accepts a path, shared `IconProps`, the explicit `kind`, and an optional project-file snapshot. Traditional file types render the supplied 28px document and folder contours as inline SVG. Excel, Markdown, PDF, PPT, and Word foreground marks scale to 122% around their visual center; the remaining marked traditional glyphs use 112%, while the file body and folded corner retain their source geometry and the generic file has no invented center mark. The sheet is a solid category color, the foreground mark and ordinary folded corner are white, and the generic file has a darker grey corner. CSS assigns the supplied category palette through static design tokens: DeepSeek blue for code/HTML/Markdown, the lighter DeepSeek blue for Word, green for Excel, two amber steps for folder/PPT, red for PDF, and neutral grey for unknown files. Image and video share the supplied violet through a component-local variable because the design platform has no matching violet token. A caller may override a traditional sheet through `--dsh-file-type-icon-color`. + +Recognized code and configuration files render the corresponding 20px square artwork scaled to the requested icon size. These technology marks retain their embedded multicolor fills and are the explicit exception to the ordinary current-color icon rule. The map selects React before TypeScript/JavaScript, Angular filename suffixes before their base extension, Docker/Node/Git/Make/CMake by filename rules, and Flutter only when the optional project snapshot contains a `pubspec.yaml` whose text includes `flutter:`. Markdown and SVG remain owned by the traditional Markdown and image categories. CSV and TSV use the code glyph in file cards, rows, and preview titles; their clickable links also use code. Both `.env` and names ending in `.env` use the environment glyph. Every traditional and technology SVG is `aria-hidden`, and the card, row, or button that owns the file identity supplies the accessible name. + +`LinkIcon` delegates extension classification to `classifyFileType` and folds the detailed result into its existing link vocabulary: code and HTML use `code`, images use `image`, PDF/Word/Excel/PPT use `document`, and Markdown/video/unknown files use `other`. Extensionless names remain `other` in link contexts, so the 14px clickable-link appearance defined by the [clickable-link decision](2026-09-04-web-clickable-link-styles.md) does not change. + +Attachment upload cards, sent-message file cards, queued-file rows, and workspace file rows render `FileTypeIcon`. The Files tab title renders its explicit `folder` kind at 16px. Explicit delivery cards also use `FileTypeIcon` at 28px and `fileExtension` for their fallback metadata. The two metadata rows use `fileExtension` rather than local parsers; a leading-dot basename such as `.env` therefore displays `ENV`, while an absent or trailing suffix displays no extension label. Image content continues to render as a preview rather than a file-type glyph, and produced-file links and Markdown file mentions continue to use `LinkIcon` because they are link surfaces. + +## Alternatives considered + +**Use `LinkIcon` for every file surface.** Rejected. Its six categories and 14px outline drawings communicate link destinations at text size; a 28px file card has room for the supplied HTML, Markdown, PDF, Word, Excel, PPT, and video identities. + +**Keep a second extension table beside `LinkIcon`.** Rejected. The same path could drift to different categories as either list grows. One detailed table plus an explicit detailed-to-link adapter preserves both consumers' semantics. + +**Put the supplied traditional-file colors directly in each SVG path.** Rejected. Traditional SVG geometry stays reusable and follows the icon set's `currentColor` rule; the component stylesheet owns the default category palette, and render sites retain one CSS-variable override instead of rewriting path fills. The technology artwork is exempt because its embedded multicolor marks identify the language or tool rather than decorating a generic file silhouette. + +**Add archive, audio, and data categories for symmetry.** Rejected. The supplied artwork and current consumers require no such glyphs. New `FileType` members require a shipped render site and artwork that remains legible at the 28px seat. + +## Testing + +The `ui-primitives` specs cover case-insensitive suffixes, both path separators, leading-dot files, missing and trailing suffixes, common named files, unknown fallback, all detailed code mappings, rule priority, Flutter context, every supplied technology SVG, instance-safe gradient ids, `aria-hidden`, sizing/class forwarding, distinct artwork, the 112% and 122% foreground-mark transforms, and the traditional solid-sheet/contrast-mark layers without literal SVG colors. A stylesheet spec pins every traditional category-to-color mapping, the caller override, and the local violet value. The existing `LinkIcon` classification table pins its coarse output, including `Makefile` remaining `other`. Attachment, chat, queue, and sidebar component suites exercise the migrated render paths; their accessibility output does not change because the glyphs remain decorative. + +## Consequences + +- Client packages use one filename parser and one detailed file-type table instead of importing or recreating feature-local logic. +- A new suffix joins the detailed table only when an existing glyph truthfully represents it. If its link category differs from the current adapter, the change must also decide whether the 14px link appearance changes. +- Code and configuration artwork preserves its embedded palette and does not accept the traditional `--dsh-file-type-icon-color` override. +- The primitive owns no copy but does own the default file-type palette. Consumers continue to own accessible labels and surrounding text, and may replace the category color through `--dsh-file-type-icon-color`. +- The detailed category names describe presentation, not MIME validation. A suffix is a display hint and does not establish file contents or trust. diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md new file mode 100644 index 0000000000..ea115cd63b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md @@ -0,0 +1,43 @@ +# Agent Note: 共享文件类型图标 + +Status: implemented + +[English](2026-09-08-shared-file-type-icons.md) | 中文 + +## 问题 + +客户端功能插件只能通过 `@deepseek-ai/dsh-client-ui-primitives` 共享 React 组件,但文件卡片没有共享的文件类型呈现。`LinkIcon` 拥有唯一一份扩展名表,并有意把路径折叠为六种链接类别;附件卡片、已发送消息附件、排队文件和工作区文件行则统一使用一个通用文档图形。其中两个消费方还各自带着一份扩展名展示辅助函数。在其他位置增加精细文件图标,需要再复制一份扩展名表或让功能插件之间产生运行时 import。 + +## 决策 + +`ui-primitives` 统一拥有一套 Cordis-free 的文件分类与渲染 API。`fileExtension(path)` 对两种路径分隔符采用共享的 basename 与最终点号语义。`classifyFileType(path)` 不区分大小写,并返回闭合的 `FileType` 联合:传统的 `code`、`excel`、`folder`、`html`、`image`、`markdown`、`other`、`pdf`、`ppt`、`video`、`word` 类别,以及由 `CodeFileIcon` 渲染的细分 `CodeFileType` 集合。解析按完整文件名、文件名前缀、文件名后缀、可选项目上下文、扩展名的顺序执行。路径分类器返回 `folder` 之外的全部成员;调用方确认条目是目录时,通过 `FileTypeIcon` 的显式 `kind` 覆盖指定目录。未知扩展名、无扩展名和末尾点号回退到 `other`,但共享表识别 `Dockerfile`、`Makefile`、`package.json`、`.gitignore`、`README`、`CHANGELOG` 等具名文件。 + +`FileTypeIcon` 接受路径、共享 `IconProps`、显式 `kind`和可选的项目文件快照。传统文件类型把所提供的 28px 文档与文件夹轮廓渲染为 inline SVG。Excel、Markdown、PDF、PPT、Word 的前景标记围绕自身视觉中心缩放至 122%,其余带标记的传统图形使用 112%;文件底板与折角保持源图几何,通用文件不凭空增加中心标记。底板使用实色分类颜色,前景标记与普通折角使用白色,通用文件使用较深的灰色折角。CSS 通过静态设计 token 分配所提供的分类调色板:code/HTML/Markdown 使用 DeepSeek 蓝,Word 使用较浅的 DeepSeek 蓝,Excel 使用绿色,folder/PPT 使用两档琥珀色,PDF 使用红色,未知文件使用中性灰。image 与 video 通过组件本地变量共用所提供的紫色,因为设计平台没有匹配的紫色 token。调用方可通过 `--dsh-file-type-icon-color` 覆盖传统底板颜色。 + +已识别的代码与配置文件把对应的 20px 方形图稿缩放到请求的图标尺寸。这些技术标记保留自身内嵌的多色填充,是普通 current-color 图标规则的明确例外。映射让 React 优先于 TypeScript/JavaScript、Angular 文件名后缀优先于基础扩展名,并按文件名识别 Docker/Node/Git/Make/CMake;只有可选项目快照包含内容带 `flutter:` 的 `pubspec.yaml` 时才选择 Flutter。Markdown 与 SVG 仍由传统 Markdown 和图片类别拥有。CSV 和 TSV 在文件卡片、文件行及预览标题中使用 code 图标,其可点击链接也使用 code。`.env` 和以 `.env` 结尾的文件名均使用环境配置图标。所有传统与技术 SVG 都是 `aria-hidden` 的,拥有文件身份的卡片、行或按钮提供无障碍名称。 + +`LinkIcon` 委托 `classifyFileType` 做扩展名分类,再把精细结果折叠进原有链接词汇:code 与 HTML 使用 `code`,图片使用 `image`,PDF/Word/Excel/PPT 使用 `document`,Markdown、video 与未知文件使用 `other`。无扩展名文件在链接语境中仍是 `other`,因此[可点击链接决策](2026-09-04-web-clickable-link-styles.zh.md)定义的 14px 外观不变。 + +附件上传卡片、已发送消息文件卡片、排队文件行和工作区文件行渲染 `FileTypeIcon`。Files 标签页标题使用显式的 `folder` 类别,尺寸为 16px。显式交付卡片也使用 28px 的 `FileTypeIcon`,并通过 `fileExtension` 提供默认元数据。两处元数据行使用 `fileExtension`,不再保留本地解析器;`.env` 这样的前导点 basename 会显示 `ENV`,无后缀或末尾点号则不显示扩展名 label。图片内容继续渲染为预览而不是文件类型图形,产物文件链接与 Markdown 文件提及继续使用 `LinkIcon`,因为它们属于链接表面。 + +## 备选方案 + +**所有文件表面都使用 `LinkIcon`。** 否决。它的六种类别和 14px outline 图形用于在文字尺寸下表达链接目标;28px 文件卡片有空间表达所提供的 HTML、Markdown、PDF、Word、Excel、PPT 与 video 身份。 + +**在 `LinkIcon` 旁保留第二份扩展名表。** 否决。任一列表增长后,同一路径可能漂移到不同类别。一份精细表加一个显式的精细到链接适配,能同时保住两类消费方的语义。 + +**把所提供的传统文件颜色直接写入每一条 SVG path。** 否决。传统 SVG 几何保持可复用,并遵守图标集的 `currentColor` 规则;组件样式表拥有默认分类调色板,渲染点只需一个 CSS 变量即可覆盖颜色,不需要重写 path 填充。技术图稿是例外,因为其内嵌的多色标记用于识别语言或工具,而不是装饰通用文件轮廓。 + +**为了对称增加 archive、audio 与 data 类别。** 否决。提供的图稿与当前消费方都不需要这些图形。新增 `FileType` 成员必须有已发布的渲染点,以及在 28px slot 中仍可辨认的图稿。 + +## 测试 + +`ui-primitives` 测试覆盖不区分大小写的后缀、两种路径分隔符、前导点文件、无后缀与末尾点号、常见具名文件、未知回退、全部细分代码映射、规则优先级、Flutter 上下文、每一份技术 SVG、实例安全的渐变 id、`aria-hidden`、尺寸/class 转发、不同图稿、112% 与 122% 前景标记变换,以及不含 SVG 字面颜色的传统实色底板/对比标记层。样式表测试钉住每一项传统类别到颜色的映射、调用方覆盖变量与本地紫色值。既有 `LinkIcon` 分类表钉住它的粗粒度输出,包括 `Makefile` 仍为 `other`。附件、聊天、队列和侧边栏组件测试覆盖迁移后的渲染路径;由于图形仍是装饰性的,其无障碍输出不变。 + +## 后果 + +- 客户端包使用一个文件名解析器与一份精细文件类型表,不再 import 或重新实现功能包本地逻辑。 +- 新后缀只在已有图形能准确表达它时加入精细表。若它的链接类别与当前适配不同,这次改动还必须决定是否改变 14px 链接外观。 +- 代码与配置图稿保留内嵌调色板,不接受传统图形的 `--dsh-file-type-icon-color` 覆盖。 +- primitive 不拥有文案,但拥有默认文件类型调色板。消费方继续拥有无障碍 label 与周围文字,并可通过 `--dsh-file-type-icon-color` 替换分类颜色。 +- 精细类别名称描述展示意图,不是 MIME 校验。后缀只是展示提示,不能证明文件内容或可信度。 diff --git a/.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.i18n.yaml index 2d8d26b371..e6392aa970 100644 --- a/.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-sidebar-default-pages.md -2026-09-08-sidebar-default-pages.md: c78d6a3af2c88c61ee5ba8fe4319d9b3f59ae7b4 -2026-09-08-sidebar-default-pages.zh.md: 8e0a081cb25862bffc20fdcac2b97c57b0080c36 +2026-09-08-sidebar-default-pages.md: 13374ab294cab74b54ada8550a6dff5af2cd9cd1 +2026-09-08-sidebar-default-pages.zh.md: 6730a87fbe9c59ef6d448fe0f2d4c3c2cb75e6bb diff --git a/.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.md b/.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.md index c78d6a3af2..13374ab294 100644 --- a/.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.md +++ b/.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.md @@ -1,4 +1,4 @@ -# Agent Note: Sidebar default pages and close protection +# Agent Note: Sidebar default pages Status: implemented @@ -6,13 +6,13 @@ English | [中文](2026-09-08-sidebar-default-pages.zh.md) ## Problem -A guide with one registered entry adds a click without offering a choice. Hiding only the last tab's close button would let an added guide make the default file browser closable again. +A guide with one registered entry adds a click without offering a choice. ## Decision The Sidebar selects each default page from the registered guide-entry list. Exactly one entry opens that entry's page; zero or multiple entries open the guide. Resource viewers without guide entries do not affect this count. Explicitly adding a guide always opens a guide, and each pane holds at most one. -A single-entry default is protected from explicit close for its record lifetime. Every pane's final tab is also protected; other tabs can close. The Sidebar stores protected record IDs and shares one close predicate between its store actions and docking controls. The generic docking kit accepts a presentation callback and has no file-browser or guide policy. Moving tabs still settles empty panes, and layout state remains memory-only. +The [last-tab close rule](2026-09-08-sidebar-last-tab-close-rules.md) owns close protection: the sole docked guide remains open, while any other sole tab closes together with the column. The generic docking kit accepts a presentation callback and has no file-browser or guide policy. Moving tabs still settles empty panes, and layout state remains memory-only. This replaces default-guide selection in [the shipped types](2026-09-05-sidebar-text-preview-and-file-tree.md) and explicit last-tab closing in [docking infrastructure](2026-09-04-right-sidebar-docking-infrastructure.md). Their registration, content-state, engine and layout ownership decisions remain active. @@ -20,8 +20,6 @@ This replaces default-guide selection in [the shipped types](2026-09-05-sidebar- **Count all registered tab types or currently open tabs.** Neither counts choices available on the guide; resource viewers need not contribute an entry. -**Protect only the final tab.** Adding a guide would expose a close control on the single-entry default, violating its retained-entry behavior. - ## Consequences -One-entry compositions open directly into their registered page without hardcoding Files. Guide selection can still replace its own tab, while ordinary close cannot empty a pane. Store and component tests cover registration counts and close protection; the assembled browser scenarios cover default Files, explicit guide creation, and returning to Files after closing the guide. +One-entry compositions open directly into their registered page without hardcoding Files. Guide selection can still replace its own tab. Store and component tests cover registration counts; the assembled browser scenarios cover default Files, explicit guide creation, and returning to Files after closing a lone tab. diff --git a/.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.zh.md b/.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.zh.md index 8e0a081cb2..6730a87fbe 100644 --- a/.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.zh.md +++ b/.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.zh.md @@ -1,4 +1,4 @@ -# Agent Note: Sidebar 默认页与关闭保护 +# Agent Note: Sidebar 默认页 Status: implemented @@ -6,13 +6,13 @@ Status: implemented ## 问题 -只有一个注册入口的引导页增加一次点击,却不提供选择。若只隐藏最后一个 tab 的关闭按钮,新增引导页后,默认文件浏览页又会变得可关闭。 +只有一个注册入口的引导页增加一次点击,却不提供选择。 ## 决策 Sidebar 从已注册的引导入口列表选择每个默认页。恰好一个入口时打开对应页面;没有入口或有多个入口时打开引导页。没有引导入口的资源查看器不影响计数。显式添加引导页始终打开引导,每个格最多持有一个。 -单入口默认页在记录生命周期内受到显式关闭保护。每个格的最后一个 tab 也受保护;其他 tab 可以关闭。Sidebar 保存受保护的记录 ID,store 动作与停靠控件共享一个关闭判定。通用停靠套件接收呈现回调,不拥有文件浏览器或引导页策略。移动 tab 仍会处理空格,布局状态仅存于内存。 +[最后一个 tab 的关闭规则](2026-09-08-sidebar-last-tab-close-rules.zh.md)负责关闭保护:作为唯一停靠 tab 的引导页保持打开,其他任何唯一 tab 都会连同整列一起关闭。通用停靠套件接收呈现回调,不拥有文件浏览器或引导页策略。移动 tab 仍会处理空格,布局状态仅存于内存。 本决策取代[随包类型](2026-09-05-sidebar-text-preview-and-file-tree.zh.md)中的默认引导选择,以及[停靠基础设施](2026-09-04-right-sidebar-docking-infrastructure.zh.md)中的显式关闭最后一个 tab。它们的注册、内容状态、引擎与布局所有权决策继续有效。 @@ -20,8 +20,6 @@ Sidebar 从已注册的引导入口列表选择每个默认页。恰好一个入 **统计所有已注册 tab 类型或已打开的 tab。** 两者都不代表引导页提供的选择;资源查看器不一定贡献入口。 -**只保护最后一个 tab。** 新增引导页后,单入口默认页会出现关闭控件,违反保留该入口的行为要求。 - ## 后果 -单入口组合直接打开已注册页面,不写死 Files。引导页选择仍可替换自身 tab,普通关闭则不能清空一个格。store 与组件测试覆盖注册数量和关闭保护;组装后的浏览器场景覆盖默认 Files、显式新增引导及关闭引导后返回 Files。 +单入口组合直接打开已注册页面,不写死 Files。引导页选择仍可替换自身 tab。store 与组件测试覆盖注册数量;组装后的浏览器场景覆盖默认 Files、显式新增引导及关闭唯一 tab 后返回 Files。 diff --git a/.agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.i18n.yaml new file mode 100644 index 0000000000..2846db77ba --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.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-sidebar-last-tab-close-rules.md +2026-09-08-sidebar-last-tab-close-rules.md: ed76b0ad0b8bbadcf5eace12a7da3bd38c733d0e +2026-09-08-sidebar-last-tab-close-rules.zh.md: bcc54c0c39cc65434f21ef8f6f81a382b6eecdb7 diff --git a/.agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.md b/.agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.md new file mode 100644 index 0000000000..ed76b0ad0b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.md @@ -0,0 +1,27 @@ +# Agent Note: Last-tab close rules on the Sidebar's docked surface + +Status: implemented + +English | [中文](2026-09-08-sidebar-last-tab-close-rules.zh.md) + +## Problem + +The settle planner guarantees the docked surface is never empty: closing the last tab reseeds the current default page. That guarantee made the last tab's close control a dead end in both directions. Closing the guide standing alone put the same guide straight back — a control that does nothing. Closing any other lone tab left the user with a column showing only the default page — after "close the last thing", an expanded panel with nothing in it is not what the gesture meant. The guide's chip also drew a hover capsule and a context menu whose only item was that no-op close. + +## Decision + +The docked surface's last tab carries one rule, decided in the Sidebar store's `closeTab` and mirrored to the kit through a new `canCloseTab(tabId)` control-policy prop (joining `canSplit` and `canAddTab`): the guide standing as the only docked tab is unclosable — no chip close control, no menu close item, and a programmatic close records nothing; any other lone tab closes together with the column in one history entry, resets fullscreen to push mode, and leaves the layout empty until the next expansion seeds the then-current default page. `soleDockedTab(state, tabId)` in [stores.ts](../../../../packages/client/ui-sidebar-right/src/client/stores.ts) names the condition; floating panels take no part in it. Per the packages rule "enforce a decision in the operation that makes it", the store's `closeTab` is the enforcement and `canCloseTab` only mirrors it into the chrome. This rule supersedes the close-protection part of [the default-page decision](2026-09-08-sidebar-default-pages.md); its selection rule remains active. + +Two kit-side presentation rules complete it in [TabPanel.tsx](../../../../packages/client/ui-dockkit/src/components/TabPanel.tsx) and [TabMenu.tsx](../../../../packages/client/ui-dockkit/src/components/TabMenu.tsx): a pane's lone chip whose close is withheld draws quiet — no capsule, no hover fill — since there is nothing to select against and nothing to do to it; and a menu that would hold no item at all produces no visible popup, so a secondary press on such a chip shows nothing rather than an empty box. + +## Alternatives considered + +**Keep the guide closable and let settle reseed it.** The visible result is a close control that does nothing; the control lies about what a press will do. + +**Hide the close in the Sidebar's renderer instead of a kit prop.** The kit draws the chip's close and the menu's close item, so the embedder cannot withhold them without a seam; a CSS override would leave the menu item live and split one decision across two owners. + +**Collapse the column from the kit when the last tab closes.** The kit has no concept of the column or its expansion; the collapse is the embedder's intent, recorded by the store alongside the close in the same entry. + +## Consequences + +`canCloseTab` is a third control-policy prop every embedder may set; leaving it out keeps every tab closable. The quiet-chip and empty-menu rules are unconditional kit behavior keyed on the same policy, so any embedder withholding a lone tab's close gets the same presentation. Reopening the column after a lone-tab close shows the default page selected from the current guide entries. Kit specs cover the withheld control, the quiet chip, and the self-dismissing menu; Sidebar unit specs cover `closeTab`'s refusal and the close-with-column entry; a [browser case](../../../../apps/web/tests/sidebar-right.e2e.ts) walks the whole rule on the rendered panel. diff --git a/.agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.zh.md b/.agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.zh.md new file mode 100644 index 0000000000..bcc54c0c39 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.zh.md @@ -0,0 +1,27 @@ +# Agent Note:Sidebar 停靠面最后一个 tab 的关闭规则 + +Status: implemented + +[English](2026-09-08-sidebar-last-tab-close-rules.md) | 中文 + +## 问题 + +settle planner 保证停靠面永不为空:关掉最后一个 tab 会重新播种当前默认页。这条保证让最后一个 tab 的关闭控件在两个方向上都走进死胡同。独自留下的引导页被关闭后,同一个引导页立刻回来——一个什么也不做的控件。任何其它 tab 独自留下时被关闭,用户面前只剩一列只显示默认页的面板——在「关掉最后一个东西」之后,一块展开着却空无内容的面板不是这个手势的本意。引导页的 chip 还画着悬停胶囊,右键菜单里唯一的条目就是那个无效的关闭。 + +## 决定 + +停靠面的最后一个 tab 带一条规则,由 Sidebar store 的 `closeTab` 决定,并经新的控制策略 prop `canCloseTab(tabId)`(与 `canSplit`、`canAddTab` 并列)镜像给套件:作为唯一停靠 tab 的引导页不可关闭——chip 上没有关闭控件,菜单里没有关闭项,编程式关闭什么都不记录;任何其它 tab 独自留下时,关闭会连同整列一起收起、把全屏重置为挤压模式并记为一条历史,布局保持为空,直到下次展开时创建当时的默认页。[stores.ts](../../../../packages/client/ui-sidebar-right/src/client/stores.ts) 里的 `soleDockedTab(state, tabId)` 命名这个条件;浮动面板不参与。按照 packages 规则「在做出决定的操作里执行它」,store 的 `closeTab` 是执行点,`canCloseTab` 只是把它镜像到界面。本规则取代[默认页决策](2026-09-08-sidebar-default-pages.zh.md)中的关闭保护部分;其默认页选择规则仍然有效。 + +两条套件侧的呈现规则在 [TabPanel.tsx](../../../../packages/client/ui-dockkit/src/components/TabPanel.tsx) 与 [TabMenu.tsx](../../../../packages/client/ui-dockkit/src/components/TabMenu.tsx) 里补全它:某格仅剩的一个 chip 在关闭被收起时画成安静样式——没有胶囊底色,没有悬停填充——因为既没有别的 tab 可供选择,也没有任何可对它做的事;一个连一项都没有的菜单不会产生可见弹层,于是对这样的 chip 次键按下什么都不显示,而不是画一个空框。 + +## 考虑过的替代方案 + +**让引导页保持可关闭,由 settle 重新播种。** 可见的结果是一个什么也不做的关闭控件;这个控件在按下会发生什么这件事上撒谎。 + +**在 Sidebar 的渲染器里藏掉关闭,而不加套件 prop。** chip 的关闭控件与菜单的关闭项都由套件绘制,没有接缝嵌入方就无法收起它们;CSS 覆盖会留下仍然生效的菜单项,把一个决定拆给两个所有者。 + +**由套件在最后一个 tab 关闭时收起整列。** 套件没有「列」或「展开」的概念;收起是嵌入方的意图,由 store 在同一条历史里与关闭一并记录。 + +## 后果 + +`canCloseTab` 成为每个嵌入方都可设置的第三个控制策略 prop;不设置时每个 tab 都可关闭。安静 chip 与空菜单两条规则是套件的无条件行为,键在同一策略上,任何收起了独 tab 关闭的嵌入方都得到同样的呈现。独 tab 关闭后重新展开的列显示根据当前引导入口选出的默认页。套件 spec 覆盖收起的控件、安静 chip 与自行消失的菜单;Sidebar 单元 spec 覆盖 `closeTab` 的拒绝与「关闭连带整列」的历史条目;一个[浏览器用例](../../../../apps/web/tests/sidebar-right.e2e.ts)在渲染出的面板上走完整条规则。 diff --git a/.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.i18n.yaml new file mode 100644 index 0000000000..5bb1142826 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.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-09-present-filesystem-access.md +2026-09-09-present-filesystem-access.md: 5c9d5bec47c73ac1c5952b887d56e582148a4cea +2026-09-09-present-filesystem-access.zh.md: 6387f07b17276b3455d19d650b4ae031a7efda4c diff --git a/.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.md b/.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.md new file mode 100644 index 0000000000..5c9d5bec47 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.md @@ -0,0 +1,27 @@ +# Agent Note: Present follows Session filesystem access + +Status: implemented + +English | [中文](2026-09-09-present-filesystem-access.zh.md) + +## Problem + +Generated files commonly live outside the workspace, especially in `/tmp`. Workspace containment rejects files that the Session filesystem and Sidebar already allow. A provider process path may also name a remote file rather than a file on the serving Host. + +## Decision + +`present` accepts existing regular files accessible through its composed `ctx.fs`, with relative paths resolved against the Session working directory. There is no workspace containment check or special temporary-directory allowlist. Missing files, directories, final symbolic links, and provider failures reject the declaration. A sandbox's private temporary files remain unavailable when the filesystem provider cannot see them. + +Native actions use the viewed Session header returned with the declaration to form the `workspaceFiles.stat` scope: its cwd, or the deployment workspace root when absent. The same composed filesystem serves Sidebar previews and native validation without activating an Agent, including for child Sessions. The resulting canonical process path must map from a Host path back to the same process path through that filesystem. Absent or different mappings produce 422 and a localized Sidebar-preview suggestion. This conservatively supports Host paths that share their canonical process spelling; providers with only a nonidentity Host mapping can still serve previews. A same-named local file never substitutes for an unmapped provider file. + +This replaces the workspace-only access rule in the [source-file delivery decision](2026-09-08-present-workspace-source-files.md). That note continues to own content-free declarations, Session events, and editing current sources. The request still selects only saved Session/event/file coordinates, never an arbitrary browser-supplied path. + +## Alternatives considered + +A `/tmp` allowlist excludes other readable output locations and duplicates filesystem policy. Treating every provider process path as a Host path can open an unrelated local file. Adding a generic inverse path-mapping API or copying remote files expands provider and retention responsibilities beyond source-file delivery. + +## Consequences + +Workspace files, accessible temporary files, Downloads, and files in another project use the same declaration rules. Native opening requires both a serving desktop and a verified Host path. Metadata checks do not make a desktop application's later path lookup atomic. + +Focused tests cover external absolute and relative paths, final symbolic links, missing files, Session lookup failures, absent and mismatched Host mappings, and localized native-unavailable state. Existing recorded Web scenarios retain declaration, preview, native action, and content-free export coverage. diff --git a/.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.zh.md b/.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.zh.md new file mode 100644 index 0000000000..6387f07b17 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.zh.md @@ -0,0 +1,27 @@ +# Agent Note:Present 遵循 Session 文件系统访问规则 + +Status: implemented + +[English](2026-09-09-present-filesystem-access.md) | 中文 + +## 问题 + +生成文件经常位于工作区外,尤其是 `/tmp`。工作区包含检查会拒绝 Session 文件系统和侧栏已经允许访问的文件。提供方的进程路径也可能指向远端文件,而非服务 Host 上的文件。 + +## 决策 + +`present` 接受所组合的 `ctx.fs` 可访问的已有普通文件,相对路径按 Session 工作目录解析。不检查工作区包含关系,也不另设临时目录白名单。文件缺失、为目录、最终路径为符号链接或提供方出错时,声明失败。文件系统提供方不可见的沙箱私有临时文件仍不可用。 + +原生操作使用与声明一起返回的当前 Session header,为 `workspaceFiles.stat` 提供工作目录:优先使用其中的 cwd,未记录时使用部署的工作目录。侧栏预览和原生校验使用同一组合文件系统,无需启动 Agent,子会话也适用。得到的规范化进程路径必须能通过该文件系统从 Host 路径映射回同一进程路径。映射缺失或不同会返回 422,并显示使用侧栏预览的本地化提示。这只支持规范化后与进程路径写法相同的 Host 路径;仅支持不同写法的 Host 映射的提供方仍可提供预览。没有映射时,本机同名文件不能替代提供方文件。 + +本决策替代[源文件交付决策](2026-09-08-present-workspace-source-files.zh.md)中只允许工作区文件的访问规则。原说明继续负责不保存内容的声明、Session 事件和编辑当前源文件。请求仍只按已保存的 Session、事件和文件索引选择文件,不接受浏览器提供的任意路径。 + +## 考虑过的替代方案 + +`/tmp` 白名单会排除其他可读的输出位置,并重复文件系统策略。把所有提供方进程路径都视作 Host 路径可能打开不相关的本机文件。增加通用反向路径映射 API 或复制远端文件,会让提供方与文件保留承担源文件交付之外的职责。 + +## 影响 + +工作区文件、可访问的临时文件、Downloads 和其他项目中的文件使用相同的声明规则。原生打开同时要求服务主机有可用桌面和经过验证的 Host 路径。元数据检查不能使桌面应用随后按路径打开文件的操作具有原子性。 + +定向测试覆盖工作区外的绝对和相对路径、最终符号链接、文件缺失、Session 查询失败、Host 映射缺失或不一致,以及本地化的原生操作不可用状态。已有的 Web 录制场景继续覆盖声明、预览、原生操作和不含文件内容的导出。 diff --git a/.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.i18n.yaml new file mode 100644 index 0000000000..530553e7cc --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.md +2026-09-09-sidebar-and-preview-interaction-polish.md: df0e768aa1aea913344c356d84c2a7d3386ccc6b +2026-09-09-sidebar-and-preview-interaction-polish.zh.md: d3f8b00fd86ec12d867d48a917cd4a65cab18ba9 diff --git a/.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.md b/.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.md new file mode 100644 index 0000000000..df0e768aa1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.md @@ -0,0 +1,35 @@ +# Agent Note: Sidebar and preview interaction polish + +Status: implemented + +English | [中文](2026-09-09-sidebar-and-preview-interaction-polish.zh.md) + +## Problem + +Five small interaction defects around the right Sidebar and the document preview. Every session's surface was born with a seeded default page, so a collapsed column the user never opened already held a page, and the first open into a fresh surface showed the seed beside the content it opened. Page tabs deduplicated surface-wide: opening a page whose tab sat in the other pane pulled focus across panes instead of opening it where asked. Dragging a pane's sole tab onto its own edge did nothing, though the user plainly asked for a split. A dropdown over the HTML preview did not dismiss on a click inside the sandboxed iframe, because that pointerdown never reaches the parent document. And the code preview's copy banner and card background scrolled away under horizontal scrolling, while the code kept the chat card's gray fill instead of sitting on the pane's own background. + +## Decision + +**Default pages seed lazily.** `createSurface` in [stores.ts](../../../../packages/client/ui-sidebar-right/src/client/stores.ts) mints a collapsed, empty surface; the store's `advance` passes the seed factory to `planSettle` only when the intent leaves the column expanded. The expansion that would first show an empty layout is what seeds the then-current default page, and closing the last closable tab collapses the column and leaves the layout empty until the next expansion. The [last-tab close rule](2026-09-08-sidebar-last-tab-close-rules.md) and [default-page selection](2026-09-08-sidebar-default-pages.md) stay as decided; default pages are created on expansion. Splitting an empty pane is a no-op: it mints no tabs, records no history, and reports no new pane. + +**Page uniqueness is pane-scoped.** The guide-only merge generalized to every page kind (`pageKind`/`panePage`): opening a page focuses an existing tab only inside the pane the open targets, and a page dragged, dropped, or docked into a pane already showing that kind's page merges into the pane's own. Resource tabs keep the kit's surface-wide reveal. + +**A sole tab splits its own pane when a factory backfills it.** `planDropTab` in [planner.ts](../../../../packages/client/ui-dockkit/src/engine/planner.ts) takes an optional `TabFactory`; with one, the previously refused self-edge release splits, the factory's tab backfills the vacated pane before the move so the dragged tab ends focused. Without a factory the release still changes nothing. + +**Menus close on focus entering an iframe.** [Menu.tsx](../../../../packages/client/ui-primitives/src/Menu.tsx) adds a window `blur` listener gated on `document.activeElement instanceof HTMLIFrameElement` — the focus move is the only signal a pointerdown inside a cross-origin iframe leaves, and the gate keeps app or tab switches from closing the list. + +**The code preview pins its banner and drops the card fill.** With wrap off, [CodeBody.module.css](../../../../packages/client/ui-sidebar-documentpreview/src/client/code/CodeBody.module.css) sizes the renderer `max-content` so the sticky banner has the full scroll width to ride, and pins the banner `sticky; left: 0; width: 100cqw` against the document scroller (`container-type: inline-size` on the preview body). The shared CodeBlock's fill is routed through a new `--dsl-code-block-background` variable (default unchanged, so chat keeps its gray card) and the shared banner carries an inert `data-code-block-banner` hook; the preview sets the variable to `transparent` so code sits on the pane's own background. + +## Alternatives considered + +**Keep seeding at surface creation.** A collapsed column held a page nobody asked for, and the seed took slot 0 of every fresh surface ahead of the first real open. + +**Keep surface-wide page dedupe.** Focus jumped to the other pane on an explicit "open here", the exact complaint that started the change. + +**A bare window-blur close for menus.** Closes the list on every app or tab switch; the `activeElement` gate scopes the close to the one case the document cannot see. + +**An inner code scroller for the horizontal axis.** Restoring `overflow-x: auto` on the `pre` keeps the banner still, but puts the horizontal scrollbar at the bottom of the whole block — unreachable in a long file — and both axes deliberately live in the document owner's scroller. + +## Consequences + +`planDropTab`'s factory parameter is new kit API any embedder may pass; `planSettle` already accepted an absent factory, which now also names the Sidebar's collapsed-state behavior. The `--dsl-code-block-background` variable and `data-code-block-banner` attribute are the code block's owner-styling seam; no shared stylesheet rule targets the attribute. Kit planner specs cover the backfilled self-split and its focus order; Sidebar store, service, and seat specs cover lazy seeding, pane-scoped page merges, and the empty collapsed layout; a Menu spec covers the gated blur close. The `ui-sidebar-right`, `ui-dockkit`, and `ui-sidebar-documentpreview` READMEs restate the rules. diff --git a/.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.zh.md b/.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.zh.md new file mode 100644 index 0000000000..d3f8b00fd8 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 侧边栏与文件预览交互打磨 + +Status: implemented + +[English](2026-09-09-sidebar-and-preview-interaction-polish.md) | 中文 + +## Problem + +右侧边栏与文档预览周边的五个小交互缺陷。每个会话的停靠面出生时就播种默认页,于是用户从未打开过的折叠列里已经躺着一个页面,向全新停靠面的第一次打开会让种子页出现在所开内容旁边。页 tab 在整个停靠面范围去重:打开一个页时,若它的 tab 在另一格里,焦点会被拽到那一格,而不是在被要求的格里打开。把一个格的唯一 tab 拖到本格边缘什么都不发生,尽管用户明明要的是分栏。HTML 预览上方的下拉菜单在点击沙箱 iframe 内部时不消失,因为那次 pointerdown 根本到不了父文档。代码预览的复制条和卡片背景会被横向滚动甩开,而且代码保留了会话卡片的灰色填充,而不是坐在分栏自身的背景上。 + +## Decision + +**默认页惰性播种。**[stores.ts](../../../../packages/client/ui-sidebar-right/src/client/stores.ts) 的 `createSurface` 铸造一个折叠且为空的停靠面;store 的 `advance` 只在意图让列保持展开时才把种子工厂传给 `planSettle`。首次会展示空布局的那次展开播种彼时的默认页;关闭最后一个可关 tab 时收起整列,布局保持为空直到下次展开。[最后一个 tab 的关闭规则](2026-09-08-sidebar-last-tab-close-rules.zh.md)与[默认页选择](2026-09-08-sidebar-default-pages.zh.md)维持原决定;默认页在展开时创建。对空分栏执行 split 不产生变化,不创建 tab,不记录历史,也不返回新分栏。 + +**页唯一性以格为界。**只对引导页的合并规则推广到每种页 kind(`pageKind`/`panePage`):打开一个页只在这次打开的目标格内聚焦既有 tab;把页拖入、放入或收回到已展示该 kind 页的格会并入该格自己的 tab。资源 tab 保留套件的全停靠面聚焦。 + +**有工厂回填时,唯一 tab 可对本格分栏。**[planner.ts](../../../../packages/client/ui-dockkit/src/engine/planner.ts) 的 `planDropTab` 接受可选 `TabFactory`:带工厂时,先前被拒绝的本格边缘释放会分栏,工厂的 tab 先于移动回填腾出的格,因此被拖的 tab 最终保持聚焦。不带工厂时该释放仍不改变任何东西。 + +**焦点进入 iframe 时关闭菜单。**[Menu.tsx](../../../../packages/client/ui-primitives/src/Menu.tsx) 增加 window `blur` 监听,以 `document.activeElement instanceof HTMLIFrameElement` 为门:焦点移动是跨源 iframe 内 pointerdown 留下的唯一信号,这道门也让应用或标签页切换不会误关列表。 + +**代码预览钉住复制条并去掉卡片填充。**关闭折行时,[CodeBody.module.css](../../../../packages/client/ui-sidebar-documentpreview/src/client/code/CodeBody.module.css) 把渲染器设为 `max-content`,让吸附的复制条拥有完整滚动宽度可骑行,并以 `sticky; left: 0; width: 100cqw` 把它钉在文档滚动区上(预览正文设 `container-type: inline-size`)。共享 CodeBlock 的填充改经新变量 `--dsl-code-block-background`(默认值不变,会话保持灰色卡片),共享复制条带上惰性的 `data-code-block-banner` 钩子;预览把变量设为 `transparent`,代码于是坐在分栏自身的背景上。 + +## Alternatives considered + +**保留出生即播种。**折叠列里躺着没人要的页面,且种子占据每个新停靠面的 0 号位,排在第一次真实打开之前。 + +**保留全停靠面的页去重。**显式"在这里打开"时焦点跳到另一格——正是引发这次改动的抱怨。 + +**菜单用裸的 window blur 关闭。**每次应用或标签页切换都会关掉列表;`activeElement` 门把关闭收窄到父文档看不见的那一种情形。 + +**代码横轴用内层滚动。**在 `pre` 上恢复 `overflow-x: auto` 能让复制条不动,但横向滚动条会落在整个代码块底部——长文件里够不着——而且两个轴本就有意放在文档 owner 的滚动区里。 + +## Consequences + +`planDropTab` 的工厂参数是任何嵌入方都可传的新套件 API;`planSettle` 本就接受缺省工厂,如今它同时命名了侧边栏的折叠态行为。`--dsl-code-block-background` 变量与 `data-code-block-banner` 属性是代码块的 owner 定制接缝;共享样式表没有任何规则指向该属性。套件 planner 规格覆盖带回填的本格分栏及其聚焦顺序;侧边栏 store、service 与 seat 规格覆盖惰性播种、格内页合并与折叠后的空布局;一条 Menu 规格覆盖带门的 blur 关闭。`ui-sidebar-right`、`ui-dockkit` 与 `ui-sidebar-documentpreview` 的 README 重述了这些规则。 diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml index 90fdf49b49..62e46dfa22 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md -2026-07-26-ci-failover-runbook.md: f24cb8b8239141cd1ccf468a566dba620dd3cfdd -2026-07-26-ci-failover-runbook.zh.md: 57c4a92a3af720d9b11b7a1ce7a1515b83c77339 +2026-07-26-ci-failover-runbook.md: 559fa61bcf416bed3bd58b3ffcbc145038cdce22 +2026-07-26-ci-failover-runbook.zh.md: e2098b928b1a1f158bcbfabd940ca23a5cd0e28e diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md index f24cb8b823..559fa61bcf 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md @@ -12,11 +12,7 @@ The three required Linux worker jobs in [CI](../../../../.github/workflows/ci.ym The three primary Linux jobs (`node-24`, `node-24-coverage`, `node-24-consumers`), the three `node-compat` matrix entries, and `all-checks-passed` resolve through `DSH_CI_FAILOVER_LINUX`; the native Windows jobs resolve through `DSH_CI_FAILOVER_WINDOWS`. A platform switch does not redirect the other platform. Set to `selfhosted` by a repository writer, the applicable trusted jobs select `vm-backup` or `dsh-win-ci`; otherwise they retain their workflow-defined hosted fallbacks. Node compatibility jobs require a same-repository, non-fork head and a non-Dependabot author, use isolated runtime setup, and retain `ubuntu-latest` fallback. Linux failover bounds snapshot concurrency and skips hosted package-cache restores. The verdict follows its workers so it does not remain queued on an unavailable hosted pool. Each switch is writer-manageable repository state, not a merge, so it works while checks are red. The `serial / linux (self-hosted standby)` and `serial / windows (self-hosted standby)` lanes re-prove the complete unsharded aggregates on master pushes. -`ci-master.yml` exempts exactly one event from `cancel-in-progress` (`${{ github.event_name != 'push' }}`), so one master push does not cancel the drill still running from the previous one. Each drill runs its complete unsharded aggregate with one gate worker, which takes longer than the interval between master merges; under unconditional cancellation a drill is superseded before reaching a verdict and the lane yields no readiness evidence for a responder to check. - -The exemption is narrower than "a drill always finishes", in two ways. GitHub keeps a single pending entry per group, so a newer pending run displaces an older one and intermediate push runs still end as `cancelled` during busy periods. And the expression is evaluated against the *newly triggered* run, so a run whose own event is not `push` — a benchmark dispatched on master within `ci-master.yml`, sharing its group `CI master-` — evaluates to `true` and does cancel a drill that is mid-flight. That is a rare manual action and the next master push restores the evidence, so it does not warrant further mechanism. What the carve-out buys is that the lane periodically reaches a verdict at all, which is what makes it usable as evidence. - -The decision belongs at workflow level because cancellation applies to the whole superseded run: a job-level `concurrency` group does not exempt its job. The negated form is load-bearing rather than cosmetic: naming `pull_request` alone would also stop cancelling `workflow_dispatch`, and each runner benchmark fans out to twelve larger runners for up to fifteen minutes inside this same group on master, so a re-dispatch would queue ahead of a drill instead of replacing a stale measurement. What bounds the cost is that a master push in `ci-master.yml` carries the [post-merge runtime and Wine checks](2026-09-06-master-only-platform-ci.md) and these two drills; the pull-request jobs live in the separate `ci.yml` (which does not see `push`), and the benchmarks are `workflow_dispatch`-gated within `ci-master.yml`. `scripts/ci-workflow.spec.ts` pins that push-reachable set — classifying by exact condition, since a negated event test mentions the event it excludes — so a new push-reachable job cannot quietly start accumulating uncancelled runs. +The [superseded-CI cancellation policy](2026-09-09-cancel-superseded-ci.md) governs master pushes and manual runs in the same workflow/ref group, including standby drills. Rapid master updates can starve a drill before it reaches a verdict. Use the latest completed standby verdict and check its age and commit before treating it as readiness evidence; a cancelled or merely scheduled run is not proof of readiness. ### Release rehearsals share the Linux switch @@ -44,7 +40,7 @@ The two switches are independent: flip only the one whose platform is degraded. ## Capacity during failover -Capacity includes the master standby, main-CI jobs, and three release-rehearsal jobs for each eligible PR or master push while the Linux switch is set. Each trusted PR also adds three Node compatibility jobs at gate concurrency one, including the build-backed Node 22 leg and cold temporary runtime downloads. The release workflows do not cancel running rehearsals when another run arrives, so overlapping refs can add sustained build, pack, and install load. Check current CPU, memory, disk, and queue pressure before extending self-hosted operation; extra registrations on this VM add scheduling slots, not machine resources. Do not infer spare capacity from the standby alone. When host resources permit extra registrations, use an org registration token (org Settings → Actions → Runners → New runner). Clone an existing runner directory **excluding its identity files** — `rsync -a --exclude '.runner*' --exclude '.credentials*' --exclude '_diag' --exclude '_work' / /` (the globs also catch `.runner_migrated`/`.credentials_migrated`, which GitHub writes on migrated runners and which equally trigger the already-configured refusal) — then run `config.sh` (copying `.runner`/`.credentials` verbatim makes it refuse with "already configured"), and **start the listener**: `sudo ./svc.sh install ubuntu && sudo ./svc.sh start`. Registration alone leaves the runner offline; a started service adds a scheduling slot, not CPU or memory. +Capacity includes the master standby, main-CI jobs, and three release-rehearsal jobs for each eligible PR or master push while the Linux switch is set. Each trusted PR also adds three Node compatibility jobs at gate concurrency one, including the build-backed Node 22 leg and cold temporary runtime downloads. The release rehearsal workflows cancel superseded runs within each workflow/ref group under the [cancellation policy](2026-09-09-cancel-superseded-ci.md); different refs can still add concurrent build, pack, and install load. Check current CPU, memory, disk, and queue pressure before extending self-hosted operation; extra registrations on this VM add scheduling slots, not machine resources. Do not infer spare capacity from the standby alone. When host resources permit extra registrations, use an org registration token (org Settings → Actions → Runners → New runner). Clone an existing runner directory **excluding its identity files** — `rsync -a --exclude '.runner*' --exclude '.credentials*' --exclude '_diag' --exclude '_work' / /` (the globs also catch `.runner_migrated`/`.credentials_migrated`, which GitHub writes on migrated runners and which equally trigger the already-configured refusal) — then run `config.sh` (copying `.runner`/`.credentials` verbatim makes it refuse with "already configured"), and **start the listener**: `sudo ./svc.sh install ubuntu && sudo ./svc.sh start`. Registration alone leaves the runner offline; a started service adds a scheduling slot, not CPU or memory. ### Switch back @@ -63,4 +59,4 @@ The variables are writer-manageable repository state; a pull request event itsel ## Consequences -Recovering from a hosted-pool outage is flipping the affected platform's variable (any writer) plus a re-run, with no merge on the critical path. The cost is a second runner topology per platform to keep working: the standby lanes exercise them on every master push so the failover targets never go stale, and the snapshot-concurrency and cache-restore branches in `ci.yml` carry a `selfhosted` leg (Linux only) that must stay in step with the hosted leg. Splitting the switch by platform adds one more variable to manage but bounds the blast radius of each switch to the jobs of a single platform. +Recovering from a hosted-pool outage is flipping the affected platform's variable (any writer) plus a re-run, with no merge on the critical path. The cost is a second runner topology per platform to keep working: master pushes schedule the standby lanes, but only completed verdicts establish readiness under the [cancellation policy](2026-09-09-cancel-superseded-ci.md), and the snapshot-concurrency and cache-restore branches in `ci.yml` carry a `selfhosted` leg (Linux only) that must stay in step with the hosted leg. Splitting the switch by platform adds one more variable to manage but bounds the blast radius of each switch to the jobs of a single platform. diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md index 57c4a92a3a..e2098b928b 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md @@ -12,11 +12,7 @@ Status: implemented 三个主要 Linux 作业(`node-24`、`node-24-coverage`、`node-24-consumers`)、三个 `node-compat` 矩阵条目和 `all-checks-passed` 通过 `DSH_CI_FAILOVER_LINUX` 解析;原生 Windows 作业通过 `DSH_CI_FAILOVER_WINDOWS` 解析。一个平台的开关不会重定向另一个平台。仓库写者将变量设为 `selfhosted` 时,适用的可信作业选择 `vm-backup` 或 `dsh-win-ci`;否则保留工作流定义的托管回退。Node 兼容性作业要求同仓库且非 fork 的头部以及非 Dependabot 作者,使用隔离运行时设置,并保留 `ubuntu-latest` 回退。Linux 故障切换限制快照并发,并跳过托管软件包缓存恢复。判定作业跟随工作作业,避免继续在不可用的托管池排队。每个开关都是写者可管理的仓库状态而非一次合并,因此在检查失败时仍然有效。`serial / linux (self-hosted standby)` 与 `serial / windows (self-hosted standby)` 通道在 master 推送上重新验证完整的未分片聚合流程。 -`ci-master.yml` 只豁免一个事件不做取消(`${{ github.event_name != 'push' }}`),因此一次 master 推送不会取消上一次推送留下的、仍在运行的演练。每次演练以单门禁工作进程执行完整的未分片聚合流程,耗时长于 master 合并的间隔;在无条件取消下,演练会在得出结论前被后续运行取代,该通道无法产出供响应者查看的就绪证据。 - -这项豁免比「演练总能跑完」要窄,有两点限制。其一,GitHub 每个组只保留一个待运行条目,更新的待运行条目会顶掉更早的,繁忙时段中间的推送运行仍会以 `cancelled` 结束。其二,该表达式是针对**新触发的运行**求值的,因此自身事件不是 `push` 的运行——例如在 `ci-master.yml` 内的 master 上派发的基准测试,与其演练共用 `CI master-` 组——求值为 `true`,会取消正在运行中的演练。这属于罕见的手动操作,且下一次 master 推送即可恢复证据,因此不值得为它再加机制。这项豁免换来的是该通道**周期性**地得出结论,而这正是它能作为证据的前提。 - -这个决定必须放在工作流级:取消作用于被取代的整个运行,作业级 `concurrency` 组并不能豁免其所属作业。采用否定式写法而非仅指名 `pull_request`,是有实质作用的:后者会连 `workflow_dispatch` 一起停止取消,而每次运行器基准测试会在 master 上的同一并发组内同时占用 12 台大规格运行器、最长 15 分钟,届时重复派发会排在演练之前,而不是替换掉已过时的测量。成本之所以可控,是因为 `ci-master.yml` 中一次 master 推送承载[合并后的运行时与 Wine 检查](2026-09-06-master-only-platform-ci.zh.md)和这两条演练;拉取请求作业位于独立的 `ci.yml`(不监听 `push`),而基准测试在 `ci-master.yml` 内受 `workflow_dispatch` 门控。`scripts/ci-workflow.spec.ts` 会锁定这个推送可达集合——按条件精确匹配,因为否定式事件判断会包含它所排除的事件名——使新的推送可达作业无法悄悄开始累积未取消的运行。 +[被取代 CI 的取消策略](2026-09-09-cancel-superseded-ci.zh.md) 管理同一工作流/引用组内的 master 推送和手动运行,包括热备演练。master 快速更新可能让演练因反复被取消而始终无法得出结论。判断就绪状态时,使用最近一次已完成的热备结论,并核对其时间和提交;已取消或仅被调度的运行不构成就绪证据。 ### 发布演练共用 Linux 开关 @@ -44,7 +40,7 @@ Status: implemented ## 切换期间的容量 -Linux 开关启用期间,容量需覆盖 master 热备、主 CI 作业,以及每个符合条件的 PR 或 master 推送的三个发布演练作业。每个可信 PR 还会增加三个门禁并发度为一的 Node 兼容性作业,包括需要构建的 Node 22 条目和冷临时运行时下载。发布工作流不会因为新运行到来而取消正在执行的演练,因此不同引用的重叠运行会增加持续的构建、打包和安装负载。延长自托管运行前,检查当前 CPU、内存、磁盘和队列压力;同一虚拟机上新增注册只增加调度槽位,不增加机器资源。不能只依据热备负载推断空闲容量。主机资源允许增加注册实例时,使用组织级注册 token(组织 Settings → Actions → Runners → New runner)。复制现有 runner 目录时**必须排除身份文件**——`rsync -a --exclude '.runner*' --exclude '.credentials*' --exclude '_diag' --exclude '_work' / /`(通配同时排除 `.runner_migrated`/`.credentials_migrated`——GitHub 会在迁移过的运行器上写入这些文件,它们同样会触发 already-configured 拒绝)——再跑 `config.sh`(原样拷贝 `.runner`/`.credentials` 会使其以 "already configured" 拒绝),然后**启动监听器**:`sudo ./svc.sh install ubuntu && sudo ./svc.sh start`。仅注册不会上线;启动服务增加的是调度槽位,而非 CPU 或内存。 +Linux 开关启用期间,容量需覆盖 master 热备、主 CI 作业,以及每个符合条件的 PR 或 master 推送的三个发布演练作业。每个可信 PR 还会增加三个门禁并发度为一的 Node 兼容性作业,包括需要构建的 Node 22 条目和冷临时运行时下载。发布演练工作流依据[取消策略](2026-09-09-cancel-superseded-ci.zh.md)取消各工作流/引用组内被取代的运行;不同引用仍可能增加并发构建、打包和安装负载。延长自托管运行前,检查当前 CPU、内存、磁盘和队列压力;同一虚拟机上新增注册只增加调度槽位,不增加机器资源。不能只依据热备负载推断空闲容量。主机资源允许增加注册实例时,使用组织级注册 token(组织 Settings → Actions → Runners → New runner)。复制现有 runner 目录时**必须排除身份文件**——`rsync -a --exclude '.runner*' --exclude '.credentials*' --exclude '_diag' --exclude '_work' / /`(通配同时排除 `.runner_migrated`/`.credentials_migrated`——GitHub 会在迁移过的运行器上写入这些文件,它们同样会触发 already-configured 拒绝)——再跑 `config.sh`(原样拷贝 `.runner`/`.credentials` 会使其以 "already configured" 拒绝),然后**启动监听器**:`sudo ./svc.sh install ubuntu && sudo ./svc.sh start`。仅注册不会上线;启动服务增加的是调度槽位,而非 CPU 或内存。 ### 切回 @@ -63,4 +59,4 @@ Linux 开关启用期间,容量需覆盖 master 热备、主 CI 作业,以 ## 后果 -从托管池故障中恢复只需切换受影响平台的变量(任何写者可设)加一次重跑,关键路径上没有合并。代价是每个平台都要维护第二套运行器拓扑:热备通道在每次 master 推送时都运行它们,避免故障切换目标变得陈旧;而 `ci.yml` 中的快照并发与缓存恢复分支带有一条 `selfhosted` 支路(仅 Linux),必须与托管支路保持同步。按平台拆分开关多了一个需要管理的变量,但把每个开关的影响范围限定在单个平台的作业上。 +从托管池故障中恢复只需切换受影响平台的变量(任何写者可设)加一次重跑,关键路径上没有合并。代价是每个平台都要维护第二套运行器拓扑:master 推送会调度热备通道,但依据[取消策略](2026-09-09-cancel-superseded-ci.zh.md),只有已完成的结论才能证明就绪状态;而 `ci.yml` 中的快照并发与缓存恢复分支带有一条 `selfhosted` 支路(仅 Linux),必须与托管支路保持同步。按平台拆分开关多了一个需要管理的变量,但把每个开关的影响范围限定在单个平台的作业上。 diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml index 692a6fe140..aec7f94a6a 100644 --- a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-10-npm-release-sequences.md -2026-08-10-npm-release-sequences.md: c039db2c7463f93f6f8847ae0ae6100df650f2a5 -2026-08-10-npm-release-sequences.zh.md: 1f14c03beb57d3a574305b348379b1542836083c +2026-08-10-npm-release-sequences.md: 19e8d8db8550816f542111a9b831a3694a080de5 +2026-08-10-npm-release-sequences.zh.md: d057d77adbf35c20fc260203d3ef0db92e30f6b7 diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md index c039db2c74..19e8d8db85 100644 --- a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md @@ -117,7 +117,7 @@ The dsh family applies the repository's publication payload policy, which reject The `pack` job walks the whole release set once, packing each member into one directory, writes the upload order, and uploads that directory as one artifact; it lives in `release.yml` / `release-vendor.yml`. The release set is one unit — half the packages can never reach the registry while the other half is still building. -`pack` carries no credentials and runs on every pull request and master push, so a pull request proves the release set still packs. Publication lives in a separate `release-publish.yml` / `release-vendor-publish.yml` workflow that is `workflow_dispatch`-only (so it never appears as a PR check): it repacks the current tree and then publishes each entry in order, behind the `npm-publish` environment for human approval. Pack runs are grouped per ref so concurrent pull requests do not displace each other; the `publish` job carries the global `Release-publish` group, because dist-tags are shared registry state. +`pack` carries no credentials and runs on every pull request and master push, so a pull request proves the release set still packs. Publication lives in a separate `release-publish.yml` / `release-vendor-publish.yml` workflow that is `workflow_dispatch`-only (so it never appears as a PR check): it repacks the current tree and then publishes each entry in order, behind the `npm-publish` environment for human approval. Pack runs are grouped per ref so concurrent pull requests do not displace each other; the `publish` job carries the global `Release-publish` group, because dist-tags are shared registry state. After a dsh publication succeeds, the release operator verifies its Session writer against the [release record](../../../../docs/session-format-status.md#updating-the-record) and updates that record when a higher Session format has shipped. A dsh verification installs the vendored family's pack output too. The harness packages declare the vendored framework as a peer, those packages live in another sequence, and the credential-free job cannot fetch them from a private registry — so the dsh `pack` job packs the vendored family for verification while publishing only the dsh set. The publish workflow (`release-publish.yml`) repacks the current tree and publishes only the dsh set. diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md index 1f14c03beb..d057d77adb 100644 --- a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md @@ -117,7 +117,7 @@ dsh 族套用仓库的发布 payload 策略(拒绝源码与声明映射)。v `pack` job 一趟遍历整个发布集,把每个成员打进同一个目录,写出上传顺序,整个目录作为一份 artifact 上传;它位于 `release.yml` / `release-vendor.yml`。发布集是一个整体——绝不会出现一半的包已经上了 registry、另一半还在构建。 -`pack` 无凭据,在每个 pull request 和每次 master push 上跑,所以一个 pull request 就能证明发布集仍能完整打出来。发布则位于独立的 `release-publish.yml` / `release-vendor-publish.yml` 工作流,仅 `workflow_dispatch`(因此不会作为 PR check 出现):它重新打包当前树,再按顺序逐个发布,挂在 `npm-publish` environment 后面等人工审批。pack 的 run 按 ref 分组,并发的 pull request 不会互相顶掉;全局 `Release-publish` 分组落在 `publish` job 上,因为 dist-tag 是共享的 registry 状态。 +`pack` 无凭据,在每个 pull request 和每次 master push 上跑,所以一个 pull request 就能证明发布集仍能完整打出来。发布则位于独立的 `release-publish.yml` / `release-vendor-publish.yml` 工作流,仅 `workflow_dispatch`(因此不会作为 PR check 出现):它重新打包当前树,再按顺序逐个发布,挂在 `npm-publish` environment 后面等人工审批。pack 的 run 按 ref 分组,并发的 pull request 不会互相顶掉;全局 `Release-publish` 分组落在 `publish` job 上,因为 dist-tag 是共享的 registry 状态。dsh 发布成功后,发布操作者按[发布记录](../../../../docs/session-format-status.zh.md#updating-the-record)核实其 Session 写入器;若交付了更高的 Session 格式,则更新该记录。 dsh 的验证会一并安装 vendored 族的 pack 产物。harness 的包把 vendored 框架声明成 peer,而那些包属于另一条序列,无凭据的 job 无法从私有 registry 取到——所以 dsh 的 `pack` job 为验证而打包 vendored 族,发布的仍只有 dsh 那一份。发布工作流(`release-publish.yml`)重新打包当前树,只发布 dsh 族。 diff --git a/.agents/notes/implemented/process/2026-09-06-master-only-platform-ci.i18n.yaml b/.agents/notes/implemented/process/2026-09-06-master-only-platform-ci.i18n.yaml index 1ce78b6e0f..6006f0447a 100644 --- a/.agents/notes/implemented/process/2026-09-06-master-only-platform-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-09-06-master-only-platform-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-06-master-only-platform-ci.md -2026-09-06-master-only-platform-ci.md: 28284206c8c6d3fbb5de8ecadbcdf2035a5bb8c0 -2026-09-06-master-only-platform-ci.zh.md: eed843b0d235c80256343890e91b1de84f482174 +2026-09-06-master-only-platform-ci.md: d32864a4eafa81433c6d4fd0e47892b4d17091b1 +2026-09-06-master-only-platform-ci.zh.md: 4fb55d5e95f8fe75e6d1efb8b57295b8e8469e8d diff --git a/.agents/notes/implemented/process/2026-09-06-master-only-platform-ci.md b/.agents/notes/implemented/process/2026-09-06-master-only-platform-ci.md index 28284206c8..d32864a4ea 100644 --- a/.agents/notes/implemented/process/2026-09-06-master-only-platform-ci.md +++ b/.agents/notes/implemented/process/2026-09-06-master-only-platform-ci.md @@ -14,7 +14,7 @@ Python runtime builds on macOS Intel and ARM and Linux ARM64, plus Windows build Wine runs once as an independent hosted Ubuntu master job. Its existing image-keyed apt cache restore/save also supplies default-branch cache production, so it needs no separate cache-seeding job. The native Linux and Windows serial aggregates do not invoke Wine. Keeping Wine hosted avoids shared-host apt transactions and shared Wine-prefix cleanup on the persistent Linux VM. The script owns a scratch snapshot, a checkout-local Wine prefix, and a checksum-verified Windows Node cache; provisioning, failure propagation, and always-run cleanup remain intact. -The parent and reusable runtime workflows preserve running master-push checks against subsequent master pushes. GitHub concurrency still permits replacement of pending runs; manual benchmarks can cancel the parent run. A master push schedules all three selected carriers but does not guarantee every intermediate commit reaches a result. PR, manual, and release cancellation retain their existing behavior. +The [superseded-CI cancellation policy](2026-09-09-cancel-superseded-ci.md) applies to the parent and reusable runtime workflows: newer master pushes or manual runs cancel older validation in the same workflow/ref group, while release-owned builds remain protected. A master push schedules all three selected carriers but does not guarantee every intermediate commit reaches a result. This decision partially supersedes scheduling in the [installed-wheel validation](../testing/2026-08-23-installed-python-wheel-black-box-ci.md), [native Windows CI](2026-08-08-native-windows-pull-request-ci.md), [serial references](2026-07-21-serial-cross-platform-ci-reference.md), and [failover runbook](2026-07-26-ci-failover-runbook.md). Those notes remain active for artifact provenance, platform fidelity, serial completeness, and trust rules. diff --git a/.agents/notes/implemented/process/2026-09-06-master-only-platform-ci.zh.md b/.agents/notes/implemented/process/2026-09-06-master-only-platform-ci.zh.md index eed843b0d2..4fb55d5e95 100644 --- a/.agents/notes/implemented/process/2026-09-06-master-only-platform-ci.zh.md +++ b/.agents/notes/implemented/process/2026-09-06-master-only-platform-ci.zh.md @@ -14,7 +14,7 @@ macOS Intel、ARM 与 Linux ARM64 上的 Python 运行时构建,以及通过 W Wine 作为独立的托管 Ubuntu master 作业运行一次。其现有的按镜像标识的 apt 缓存恢复和保存也负责生成默认分支缓存,因此不需要单独的缓存预热作业。原生 Linux 与 Windows 串行聚合不调用 Wine。Wine 保持托管运行,避免在持久 Linux VM 上执行共享宿主机 apt 事务和共享 Wine prefix 清理。脚本负责临时快照、checkout 内的 Wine prefix 和经过校验和验证的 Windows Node 缓存;环境准备、失败传播及始终执行的清理保持不变。 -父工作流与可复用运行时工作流均保留正在执行的 master 推送检查,不被后续 master 推送取消。GitHub 并发机制仍允许替换待执行的运行;手动基准测试可以取消父工作流。master 推送会调度全部三个选定载体,但不保证每个中间提交都得到结果。PR(Pull Request)、手动和发布运行的取消行为保持不变。 +[被取代 CI 的取消策略](2026-09-09-cancel-superseded-ci.zh.md) 适用于父工作流与可复用运行时工作流:更新的 master 推送或手动运行会取消同一工作流/引用组内的旧验证,而发布所属的构建仍受保护。master 推送会调度全部三个选定载体,但不保证每个中间提交都得到结果。 本决策部分取代[安装后 wheel 包验证](../testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)、[原生 Windows CI](2026-08-08-native-windows-pull-request-ci.zh.md)、[串行参考](2026-07-21-serial-cross-platform-ci-reference.zh.md)和[故障切换手册](2026-07-26-ci-failover-runbook.zh.md)中的调度策略。这些记录仍保留产物来源、平台保真度、串行完整性与信任规则的决策价值。 diff --git a/.agents/notes/implemented/process/2026-09-08-comment-only-review-routing.md b/.agents/notes/implemented/process/2026-09-08-comment-only-review-routing.md deleted file mode 100644 index 050905285b..0000000000 --- a/.agents/notes/implemented/process/2026-09-08-comment-only-review-routing.md +++ /dev/null @@ -1,41 +0,0 @@ -# Agent Note: Exclude documentation and comment-only changes from review routing - -Status: implemented - -English | [中文](2026-09-08-comment-only-review-routing.zh.md) - -## Problem - -Directory ownership alone treats documentation and comment edits like executable changes. These edits do not require the automatic code-owner request that protects behavior changes. - -GitHub may omit or truncate a file patch. A scanner that assumes every patch is complete can miss executable changes that occur outside the supplied hunks. - -## Decision - -Review routing classifies every old and new path in this order: test, documentation, comment-only, then reviewable code. Test classification wins when a test path also has a documentation extension. Every filename ending in `.md` or `.yaml`, matched without case sensitivity, is documentation. A `.yml` file is not documentation under this rule. - -Comment-only classification applies only to files with `status: modified` and a declared source-comment syntax. The scanner reconstructs the before and after text for each patch hunk, removes comments outside quoted strings, removes empty lines left by comments, and requires the remaining text to be identical. - -The scanner counts added and deleted patch lines and compares them with GitHub's file record before accepting a comment-only result. A missing patch, a count mismatch, a rename, an unsupported extension, or a comment form that remains visible to the lexer keeps the file reviewable. This fail-safe result can request an unnecessary review but cannot suppress a known code change. - -The supported lexical rules cover C-style line and block comments, hash comments, SQL comments, CSS block comments, and HTML comments for an explicit extension set in the scanner. Comment directives such as JSDoc tags, lint controls, compiler controls, and coverage controls are comments for routing purposes. - -## Verification - -[Scanner tests](../../../../.github/review-ownership/request-review.test.mjs) cover documentation extensions, supported comment forms, quoted comment markers, executable token changes, incomplete patches, renames, unsupported extensions, exclusion precedence, and the no-request result when every file is excluded. - -## Alternatives considered - -**Keep every non-test file reviewable.** This requests code owners for documentation and comment maintenance even though the routing policy is intended to identify executable changes. - -**Infer arbitrary semantic equivalence.** Proving behavior equivalence across the repository's languages requires language toolchains and still cannot assign one stable meaning to generated files, configuration, or build directives. The scanner performs only lexical comment removal. - -**Trust every patch returned by GitHub.** GitHub can omit or truncate patches. Matching the patch's added and deleted line counts to the file record prevents a partial patch from producing a comment-only verdict. - -**Fetch and parse every complete file revision.** Per-file content requests multiply API traffic for large pull requests and still require the same language-specific parsing. The changed-file response already carries enough evidence for complete ordinary patches. - -## Consequences - -Documentation and proven comment-only changes request nobody. The workflow logs them separately from tests so maintainers can audit why owner matching ignored a file. - -Unsupported or incomplete inputs remain reviewable. Comment directives do not request owners even when another tool interprets them, because this policy classifies their lexical form rather than downstream tool behavior. diff --git a/.agents/notes/implemented/process/2026-09-08-comment-only-review-routing.zh.md b/.agents/notes/implemented/process/2026-09-08-comment-only-review-routing.zh.md deleted file mode 100644 index b98f5d70b4..0000000000 --- a/.agents/notes/implemented/process/2026-09-08-comment-only-review-routing.zh.md +++ /dev/null @@ -1,41 +0,0 @@ -# Agent Note: 从评审路由中排除文档和纯注释变更 - -Status: implemented - -[English](2026-09-08-comment-only-review-routing.md) | 中文 - -## 问题 - -只按目录分配 owner 会把文档和注释编辑视为可执行变更。这些编辑不需要用于保护行为变更的自动代码 owner 请求。 - -GitHub 可能省略或截断文件 patch。如果扫描器假定每个 patch 都完整,就可能漏掉位于已提供 hunk 之外的可执行变更。 - -## 决策 - -评审路由按测试、文档、纯注释、可评审代码的顺序对每个新旧路径分类。当测试路径同时具有文档扩展名时,测试分类优先。所有以 `.md` 或 `.yaml` 结尾的文件均视为文档,扩展名匹配不区分大小写;此规则不把 `.yml` 文件视为文档。 - -纯注释分类只适用于 `status: modified` 且已声明源码注释语法的文件。扫描器重建每个 patch hunk 的变更前后文本,移除引号字符串外的注释和注释留下的空行,并要求其余文本完全相同。 - -扫描器会统计 patch 的新增行和删除行,并在接受纯注释结果前与 GitHub 文件记录比较。缺失 patch、计数不符、重命名、不受支持的扩展名,或词法分析器仍能看到的注释形式都会使文件保持可评审状态。该保守结果可能产生不必要的评审请求,但不会隐藏已知代码变更。 - -受支持的词法规则按扫描器中显式的扩展名集合覆盖 C 风格行注释和块注释、井号注释、SQL 注释、CSS 块注释及 HTML 注释。JSDoc 标签、lint 控制、编译器控制和覆盖率控制等注释指令在评审路由中仍属于注释。 - -## 验证 - -[扫描器测试](../../../../.github/review-ownership/request-review.test.mjs)覆盖文档扩展名、受支持的注释形式、引号内的注释标记、可执行 token 变更、不完整 patch、重命名、不受支持的扩展名、排除优先级,以及所有文件均被排除时不发出请求的结果。 - -## 考虑过的替代方案 - -**让每个非测试文件都保持可评审。** 这会为文档和注释维护请求代码 owner,但该路由策略的目标是识别可执行变更。 - -**推断任意语义等价。** 证明仓库中多种语言的行为等价需要各语言工具链,而且仍然无法为生成文件、配置或构建指令提供一种稳定含义。扫描器只执行词法注释移除。 - -**信任 GitHub 返回的每个 patch。** GitHub 可能省略或截断 patch。将 patch 的新增和删除行数与文件记录匹配,可以防止不完整 patch 产生纯注释结论。 - -**获取并解析每个文件的完整修订版本。** 对于大型 PR,逐文件内容请求会增加多倍 API 流量,而且仍需相同的语言专用解析。普通完整 patch 所需的证据已包含在变更文件响应中。 - -## 后果 - -文档和确认的纯注释变更不会请求任何人。Workflow 会将它们与测试分开记录,以便维护者检查 owner 匹配忽略文件的原因。 - -不受支持或不完整的输入仍需评审。即使其他工具会解释注释指令,这些指令也不会请求 owner,因为该策略按词法形式分类,而不是按下游工具行为分类。 diff --git a/.agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.md b/.agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.md deleted file mode 100644 index b283624f39..0000000000 --- a/.agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.md +++ /dev/null @@ -1,55 +0,0 @@ -# Agent Note: Route reviews from trusted changed-file policy - -Status: implemented - -## Problem - -GitHub's native CODEOWNERS behavior requests reviewers whenever a matching path changes. It cannot apply this repository's distinction between reviewable implementation or documentation files and test-only evidence. A native CODEOWNERS file also makes GitHub, rather than an inspected repository program, responsible for the request decision. - -Review routing needs an observable changed-file input, explicit owner rules, complete test exclusions, and a write-capable workflow that remains safe for pull requests from forks. - -## Decision - -The repository keeps a CODEOWNERS-compatible map at [`.github/review-ownership/CODEOWNERS`](../../../../.github/review-ownership/CODEOWNERS), outside GitHub's native CODEOWNERS locations. The map accepts only explicit absolute directory patterns with one or two individual GitHub users. It rejects wildcards, hidden-directory patterns, teams, more than two owners, duplicate patterns, and duplicate owners. Later matching patterns replace earlier matches. - -The policy test counts non-test tracked lines in directories that match an ownership rule. It rejects a map in which `@turtle1999` owns more than one third of that eligible owned codebase. - -The [`request-review` workflow](../../../../.github/workflows/request-review.yml) runs on `pull_request_target` events for opened, synchronized, reopened, ready-for-review, and converted-to-draft pull requests. Its write-capable job checks out the default branch and executes only the default branch's scanner and ownership map. It does not check out pull-request code or read repository secrets. - -The scanner fetches every changed-file record before deciding. It fails if the pull request reports more than GitHub's 3,000-file API limit or if pagination returns an incomplete list. It normalizes repository paths, evaluates old and new paths of a rename independently, and escapes filenames before logging them. - -The scanner excludes test-only paths before owner matching. Excluded paths comprise directories named `test`, `tests`, `__tests__`, `__snapshots__`, `benches`, or `stress-tests`; the top-level `benchmarks` and `snapshots` trees; `packages/test-support`; `scripts/fixtures` and `scripts/snapshots`; filenames ending in `.bench.`, `.corpus.`, `.e2e.`, `.perf.`, `.snapshot.`, `.spec.`, `.stress.`, or `.test.`; and Python `test_*.py`, `*_test.py`, or `*_tests.py` files. Test infrastructure such as `vitest*.config.ts` and gate implementations remains reviewable because it changes how repository evidence is produced. The [comment-only routing decision](2026-09-08-comment-only-review-routing.md) owns the additional documentation and comment exclusions. - -The workflow prints the changed code paths, each exclusion class, per-file owner matches and changed LOC, aggregate owner relevance, approved owners omitted from new requests, current individual requests, the available counted slot after planned cancellations, and final reviewer actions before any review-request mutation. For a non-draft pull request, it fetches the complete chronological review list and reduces each owner's undismissed `APPROVED` and `CHANGES_REQUESTED` reviews to the latest decisive state; `COMMENTED` and `PENDING` reviews leave that state unchanged. It removes the pull-request author, owners with an active approval, and users who remain requested from the matched individual owners. An active approval remains sufficient after later synchronize events, while a later changes-requested review makes the owner eligible again. The review-list operation fails before mutation at 3,000 entries or on an invalid record. - -The workflow keeps at most one current individual review request other than `@turtle1999`. An existing request for `@turtle1999` does not consume that slot, but each workflow run adds at most one reviewer. An existing non-turtle request leaves no slot, so the workflow does not add anyone, including `@turtle1999`. Existing individual requests consume the slot even when they do not match the ownership map. An owner's relevance is the sum of GitHub-reported additions and deletions for each reviewable changed-file record whose current or previous path matches that owner. Each record contributes once per owner, including when both paths of a rename match the same owner. Higher changed LOC selects candidates first when the available slot cannot cover the remaining owners; login order resolves equal scores. - -When current review requests exist, the workflow reads the complete review-request timeline before mutation. A current reviewer is workflow-authored only when its latest matching `review_requested` event identifies `github-actions[bot]` as `review_requester`; a request without an attributable event is preserved. A non-draft run cancels workflow-authored reviewers that no longer match the current candidates and excess workflow-authored non-turtle reviewers above the counted limit; current relevance order selects which matching workflow reviewer remains. Planned cancellations release capacity before the workflow selects a new reviewer. A draft run cancels every current workflow-authored request. Requests made by people remain unchanged. An attributable event with invalid provenance and timelines above 3,000 events fail before mutation. - -## Verification - -[Scanner tests](../../../../.github/review-ownership/request-review.test.mjs) cover admitted ownership syntax, rejected syntax, each exclusion class, production-name negative controls, renames, last-match behavior, unmatched files, changed-LOC aggregation and ranking, complete pagination, file and review limits, approval-state reduction, approved-owner suppression and next-owner selection, log-before-mutation ordering, author and existing-reviewer filtering, non-draft reconciliation, draft cancellation provenance, and API failures. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) pin the event set, least permissions, trusted default-branch checkout, absence of pull-request-head references and secrets, and executed command. The gate graph includes both suites in static CI and `check-all`. - -## Alternatives considered - -**Use native CODEOWNERS.** Native routing cannot ignore test-only changes and offers no repository-owned decision log before requesting reviewers. - -**Run under `pull_request` and check out the pull-request head.** A fork workflow does not receive a write-capable token, while granting a write token to code from an untrusted head is unsafe. - -**Execute the pull request's scanner or owner map under `pull_request_target`.** This lets an untrusted pull request choose its own write-capable behavior or owners. - -**Select capped candidates by login order.** Login order is stable but ignores how much reviewable code changed under each owner's directories. Changed LOC makes the limited requests follow the pull request's strongest ownership relevance while retaining login order for ties. - -**Cancel every reviewer that no longer matches.** A person may request a reviewer for reasons outside the ownership map. Only requests attributed to the workflow identity are safe for automated reconciliation. - -**Treat an empty current request as an owner who still needs review.** GitHub removes the pending request when the reviewer submits a review. Requesting an owner with an active approval again adds no ownership coverage and creates repeated notifications after later synchronize events. - -**Infer arbitrary semantic source changes from patches or language parsers.** GitHub can omit or truncate patches, and the repository spans many languages. The scanner does not try to prove that two programs behave identically. The later [comment-only routing decision](2026-09-08-comment-only-review-routing.md) adds a narrow lexical comparison only when changed-line counts prove that GitHub supplied the complete patch. - -## Consequences - -Reviewer mutations are reproducible from a trusted policy, the file classifications printed in the workflow log, and review-request provenance in the pull-request timeline. Excluded changes do not request owners, rule and changed-file updates remove obsolete workflow-authored requests on the next run, and draft pull requests do not retain workflow-authored requests. Ownership changes become effective only after merge, so the pull request that changes policy cannot apply its untrusted policy to itself. - -The workflow requests at most one reviewer per run, does not repeat a request while that owner has an active approval, keeps no more than one current individual reviewer other than `@turtle1999`, and prefers owners whose matched reviewable files carry more changed LOC. An existing `@turtle1999` request leaves the counted slot available; an existing non-turtle request prevents every additional request. Shared ownership gives each owner the same file-level relevance without counting one renamed file twice for the same owner. GitHub-generated review-request events may not start other workflows that depend on recursively triggered events from `GITHUB_TOKEN`; those workflows must not rely on this request as their only trigger. - -Any change that does not match an explicit exclusion remains eligible under an owned directory. Unmatched paths are logged and request nobody. Pull requests above the file, review, or timeline API limit fail without applying a partial reviewer mutation. 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/process/2026-09-09-cancel-superseded-ci.i18n.yaml b/.agents/notes/implemented/process/2026-09-09-cancel-superseded-ci.i18n.yaml new file mode 100644 index 0000000000..5ef13299ed --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-09-cancel-superseded-ci.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-cancel-superseded-ci.md +2026-09-09-cancel-superseded-ci.md: 8de1f757870a2b18852a224ec2f1cc33d9f6a95e +2026-09-09-cancel-superseded-ci.zh.md: 3ae6ec6ad58b0dfc94e803a33ba92ded4e239485 diff --git a/.agents/notes/implemented/process/2026-09-09-cancel-superseded-ci.md b/.agents/notes/implemented/process/2026-09-09-cancel-superseded-ci.md new file mode 100644 index 0000000000..8de1f75787 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-09-cancel-superseded-ci.md @@ -0,0 +1,39 @@ +# Agent Note: Cancel superseded CI validation + +Status: implemented + +English | [中文](2026-09-09-cancel-superseded-ci.zh.md) + +## Problem + +Validation of an obsolete PR revision or master commit consumes runner capacity without establishing the newest revision’s status. Unconditional aggregate verdicts and coverage-history uploads can also keep cancelled runs doing bookkeeping. Preserving older post-merge runs favors historical completion over current validation, especially on the shared self-hosted pools. + +## Decision + +Validation favors the newest run within each workflow/ref group. [CI](../../../../.github/workflows/ci.yml), [CI master](../../../../.github/workflows/ci-master.yml), [real-API e2e](../../../../.github/workflows/e2e.yml), and the credential-free [dsh](../../../../.github/workflows/release.yml) and [vendor](../../../../.github/workflows/release-vendor.yml) pack validations use `cancel-in-progress: true` with `${{ github.workflow }}-${{ github.ref }}`. Different PR refs and different workflows do not cancel each other. Event type is not part of the group: master pushes and manual benchmarks can supersede each other in CI master, and e2e pushes, scheduled runs, and manual runs can supersede each other on the same ref. + +The [reusable Python runtime builder](../../../../.github/workflows/build-exe-for-python-sdk.yml) uses `${{ !inputs.release }}`. Its `build-single-exe-${{ github.workflow }}-${{ github.ref }}` group remains distinct from its caller’s group, and the caller workflow name isolates ordinary CI from release-owned builds. Release-owned builds are exempt because they belong to an intentional publication transaction. Publication, deployment, and metadata workflows retain their own policies; this decision does not apply cancellation indiscriminately across workflows. + +The PR aggregate uses `${{ !cancelled() && github.event_name == 'pull_request' }}`. The explicit status function preserves evaluation after failed or skipped dependencies rather than accepting GitHub’s default success-only condition. The aggregate still fails on any failure, cancellation, or skip among its dependencies when the workflow itself is not cancelled; cancellation of the whole workflow suppresses its obsolete verdict. Coverage duration history uses `!cancelled()` too: failed coverage can still save useful measurements, but cancelled coverage does not upload them. Wine’s `always()` cleanup remains necessary resource cleanup rather than optional bookkeeping. + +This reverses the cancellation exemption in the [failover runbook](2026-07-26-ci-failover-runbook.md), [master-only platform CI](2026-09-06-master-only-platform-ci.md), and [real-API e2e decision](../testing/2026-06-19-real-api-e2e-ci.md). Those notes retain independent value for pool trust and switching, platform coverage, and secret exposure. The [release rehearsal decision](2026-09-06-release-rehearsal-selfhosted.md) retains runner selection and isolation ownership. None is fully superseded or archived. + +## Alternatives considered + +**Preserve running master-push drills.** The former `${{ github.event_name != 'push' }}` exemption favored periodic readiness evidence: each standby executes its complete unsharded aggregate with one gate worker and can outlast the interval between master merges. Even that policy did not guarantee every drill completed. GitHub retains one pending run per group, replacing intermediate pending pushes; cancellation is evaluated on the newly triggered run, so a manual benchmark sharing the master group could still cancel a drill. That rare manual interruption was accepted on the expectation of evidence from a subsequent push. The exemption’s cost was bounded by the master-only runtime checks, Wine, and two drills; PR jobs remained in a separate workflow, and exact-condition regression checks pinned the push-reachable job set. This policy is rejected in favor of freeing capacity for current validation, explicitly accepting standby starvation. + +**Protect a drill with job-level concurrency, or cancel only PR events.** A job-level group cannot exempt a job from cancellation of its entire workflow. A PR-only cancellation condition also exempts manual dispatch: a repeated runner benchmark can occupy twelve larger runners for up to fifteen minutes rather than replacing an obsolete measurement. Workflow-level cancellation covers both pushes and manual runs. + +**Keep every post-merge, nightly, and pack run.** Historical completion provides more per-commit and per-trigger evidence, but obsolete validation competes with the newest run. These validations do not publish packages, so preserving every run is not the same requirement as protecting an intentional publication transaction. + +**Replace every `always()` condition.** Failure aggregation and resource cleanup have different obligations. A success-only aggregate can hide failed dependencies behind a skipped required check; removing unconditional Wine cleanup can leave resources running. Only cancelled-run bookkeeping is suppressed. + +## Consequences + +Rapid master updates can repeatedly cancel the longer standby drills before they produce a verdict. Operators use the latest completed standby verdict, checking its age and commit before relying on it for failover readiness; a scheduled, running, or cancelled drill is not readiness evidence. The policy does not guarantee that every intermediate commit, nightly trigger, or benchmark completes. Different refs can still compete for shared host capacity. + +Cancellation is a request handled by GitHub Actions and its runners, not a guarantee of immediate termination or bounded queue delay. Cleanup can still take time. The policy makes obsolete validation cancellable; it does not promise a fixed runtime or cancellation latency. + +## Verification + +[Workflow regressions](../../../../scripts/ci-workflow.spec.ts) pin workflow/ref isolation, release-owned exemptions, aggregate status conditions, coverage-history cancellation, and retained Wine cleanup. [Platform routing regressions](../../../../scripts/tests/ci-master-platforms.spec.ts) preserve the master/PR target split and release matrix; [release rehearsal regressions](../../../../scripts/tests/ci-release-selfhosted.spec.ts) preserve cancellation alongside runner eligibility and publication isolation. These configuration checks do not reproduce GitHub scheduling or runner shutdown. Live supersession and completed standby evidence remain CI verification responsibilities. diff --git a/.agents/notes/implemented/process/2026-09-09-cancel-superseded-ci.zh.md b/.agents/notes/implemented/process/2026-09-09-cancel-superseded-ci.zh.md new file mode 100644 index 0000000000..3ae6ec6ad5 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-09-cancel-superseded-ci.zh.md @@ -0,0 +1,39 @@ +# Agent Note: 取消被取代的 CI 验证 + +Status: implemented + +[English](2026-09-09-cancel-superseded-ci.md) | 中文 + +## 问题 + +验证已被取代的 PR(Pull Request)修订或 master 提交会消耗运行器容量,却不能确定最新修订的状态。无条件执行的聚合判定和覆盖率耗时历史上传,还可能让已取消的运行继续处理记账任务。保留旧的合并后运行,意味着优先完成历史验证而非当前验证,在共享自托管池上尤其如此。 + +## 决策 + +验证优先保留各工作流/引用组内最新的运行。[CI](../../../../.github/workflows/ci.yml)、[CI master](../../../../.github/workflows/ci-master.yml)、[真实 API e2e](../../../../.github/workflows/e2e.yml),以及无凭据的 [dsh](../../../../.github/workflows/release.yml) 和 [vendor](../../../../.github/workflows/release-vendor.yml) 打包验证,均在 `${{ github.workflow }}-${{ github.ref }}` 组中使用 `cancel-in-progress: true`。不同 PR 引用和不同工作流不会相互取消。事件类型不参与分组:CI master 中的 master 推送与手动基准测试可以相互取代,e2e 的推送、定时运行和手动运行也可以在同一引用上相互取代。 + +[可复用 Python 运行时构建器](../../../../.github/workflows/build-exe-for-python-sdk.yml)使用 `${{ !inputs.release }}`。其 `build-single-exe-${{ github.workflow }}-${{ github.ref }}` 组与调用方的组保持区分,调用方工作流名称将普通 CI 与发布所属的构建隔离。发布所属的构建获得豁免,因为它们属于一次有意发起的发布事务。发布、部署和元数据工作流保留各自的策略;本决策不会不加区分地对所有工作流应用取消。 + +PR 聚合使用 `${{ !cancelled() && github.event_name == 'pull_request' }}`。显式状态函数使其在依赖失败或跳过后仍然求值,而非采用 GitHub 默认的仅成功条件。当工作流本身未被取消时,聚合仍会因任意依赖失败、取消或跳过而失败;整个工作流被取消时则抑制其已失去用途的判定。覆盖率耗时历史也使用 `!cancelled()`:覆盖率失败时仍可保存有用的测量数据,但被取消的覆盖率运行不上传。Wine 的 `always()` 清理仍是必要的资源清理,而非可选记账任务。 + +本决策推翻[故障切换手册](2026-07-26-ci-failover-runbook.zh.md)、[仅 master 执行的平台 CI](2026-09-06-master-only-platform-ci.zh.md) 和[真实 API e2e 决策](../testing/2026-06-19-real-api-e2e-ci.zh.md)中的取消豁免。这些记录对运行器池信任与切换、平台覆盖和密钥暴露仍有独立价值。[发布演练决策](2026-09-06-release-rehearsal-selfhosted.zh.md)仍负责运行器选择与隔离。没有记录被完全取代或归档。 + +## 曾考虑的替代方案 + +**保留正在执行的 master 推送演练。** 原有 `${{ github.event_name != 'push' }}` 豁免优先保证周期性就绪证据:每条热备以单门禁工作进程执行完整的未分片聚合流程,耗时可能长于 master 合并间隔。即便该策略也不保证每次演练完成。GitHub 每个组仅保留一个待运行条目,会替换中间的待执行推送;取消条件针对新触发的运行求值,因此共享 master 组的手动基准测试仍可取消演练。当时接受了这种罕见的手动中断,期望后续推送提供证据。豁免成本被限定为仅 master 执行的运行时检查、Wine 和两条演练;PR 作业仍在独立工作流中,按精确条件匹配的回归检查固定推送可达作业集合。为释放容量给当前验证而否决该策略,明确接受热备因反复被取消而无法完成。 + +**用作业级并发保护演练,或仅取消 PR 事件。** 作业级分组不能让作业免于整个工作流的取消。仅针对 PR 的取消条件还会豁免手动触发:重复派发的运行器基准测试可能占用十二台大型运行器长达十五分钟,而非替换陈旧的测量。工作流级取消同时覆盖推送和手动运行。 + +**保留每次合并后、每夜和打包运行。** 完成历史运行能提供更多按提交和触发划分的证据,但已被取代的验证会与最新运行竞争。这些验证不发布包,因此保留每次运行与保护有意发起的发布事务并不是同一项要求。 + +**替换每个 `always()` 条件。** 失败聚合与资源清理承担不同义务。仅成功时执行的聚合可能把依赖失败隐藏为跳过的必需检查;移除无条件 Wine 清理则可能留下仍在运行的资源。只有已取消运行的记账任务被抑制。 + +## 后果 + +master 快速更新可能反复取消耗时更长的热备演练,使其无法产出结论。运维人员使用最近一次已完成的热备结论,并在依赖它判断故障切换就绪状态前核对其时间和提交;已调度、正在执行或已取消的演练都不构成就绪证据。该策略不保证每个中间提交、每夜触发或基准测试都能完成。不同引用仍会竞争共享主机容量。 + +取消是由 GitHub Actions 及其运行器处理的请求,不保证立即终止或限定排队时长。清理仍可能耗时。该策略让已被取代的验证可以被取消,但不承诺固定运行时长或取消延迟。 + +## 验证 + +[工作流回归测试](../../../../scripts/ci-workflow.spec.ts)固定工作流/引用隔离、发布所属构建豁免、聚合状态条件、覆盖率历史取消及保留的 Wine 清理。[平台路由回归测试](../../../../scripts/tests/ci-master-platforms.spec.ts)保留 master/PR 目标划分与发布矩阵;[发布演练回归测试](../../../../scripts/tests/ci-release-selfhosted.spec.ts)在验证取消策略的同时保留运行器准入与发布隔离。这些配置检查不重现 GitHub 调度或运行器停止过程。真实运行取代行为与已完成热备证据仍由 CI 负责验证。 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-06-19-real-api-e2e-ci.i18n.yaml b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml index 676b7c29a9..e50f4af442 100644 --- a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-real-api-e2e-ci.md -2026-06-19-real-api-e2e-ci.md: 4f51032d90b6773d01db7bbfdced6328bd882ad0 -2026-06-19-real-api-e2e-ci.zh.md: 390df23aebec3a6a54bc48eb52022fe02e872164 +2026-06-19-real-api-e2e-ci.md: b218d77f0817416b01459fbb70f5cacffe048ee8 +2026-06-19-real-api-e2e-ci.zh.md: 52c988dad00ddb3ee7f0cd2d23da9771f0900808 diff --git a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md index 4f51032d90..b218d77f08 100644 --- a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md +++ b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md @@ -54,7 +54,7 @@ The repo secret is named `DEEPSEEK_API_KEY_EXTERNAL`; it is mapped to the `DEEPS ### Scope, runtime shape -The job runs only `test:e2e` on Node 24; keyless gates and version compatibility belong to the main CI workflow. Tests run unbuilt through the workspace paths map with a bounded configurable worker pool, per-test retries, and a job timeout. Superseded PR runs are cancelled, while push and scheduled runs complete for post-merge signal. +The job runs only `test:e2e` on Node 24; keyless gates and version compatibility belong to the main CI workflow. Tests run unbuilt through the workspace paths map with a bounded configurable worker pool, per-test retries, and a job timeout. The [superseded-CI cancellation policy](../process/2026-09-09-cancel-superseded-ci.md) cancels older runs in the same workflow/ref group across PR, push, schedule, and manual triggers; a post-merge or nightly trigger does not guarantee completion. The DeepSeek native `web_search` probe is registered but skipped. The live Anthropic-compatible endpoint can return a successful response without structured source blocks, so its positive-source assertion is not a reliable merge signal; unit coverage still pins response parsing, but CI does not prove the live source-block wire shape. diff --git a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md index 390df23aeb..52c988dad0 100644 --- a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md +++ b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md @@ -54,7 +54,7 @@ repo secret 命名为 `DEEPSEEK_API_KEY_EXTERNAL`;映射到适配器和测试 ### 范围与运行时形态 -job 仅在 Node 24 上运行 `test:e2e`;无密钥门禁和版本兼容性属于主 CI 工作流。测试通过 workspace paths 映射以未构建形式运行,使用有界的可配置 worker 池、逐测试重试和 job 超时。被取代的 PR 运行会被取消,而 push 和 schedule 运行完整执行以提供合并后信号。 +job 仅在 Node 24 上运行 `test:e2e`;无密钥门禁和版本兼容性属于主 CI 工作流。测试通过 workspace paths 映射以未构建形式运行,使用有界的可配置 worker 池、逐测试重试和 job 超时。[被取代 CI 的取消策略](../process/2026-09-09-cancel-superseded-ci.zh.md)会在 PR、push、schedule 和手动触发之间取消同一工作流/引用组内的旧运行;合并后或每夜触发并不保证完成。 DeepSeek 原生 `web_search` 探测已注册但会跳过。线上 Anthropic 兼容端点可能返回成功响应却没有结构化来源块,因此对来源存在性的正向断言不是可靠的合并信号;单元测试仍会锁定响应解析行为,但 CI 不会验证线上端点返回的来源块协议格式(wire format)。 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-08-24-session-log-snapshot-corpus.i18n.yaml b/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.i18n.yaml index c2a7bcd232..9cb458cb68 100644 --- a/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-08-24-session-log-snapshot-corpus.md -2026-08-24-session-log-snapshot-corpus.md: ebcd9709f9cd17ad288d787a13ca66efee5fcc42 -2026-08-24-session-log-snapshot-corpus.zh.md: 8d0614e150c927b491784b51a166e752a7718dae +2026-08-24-session-log-snapshot-corpus.md: a9001c2eabd610e08cfb1177f2b1339f306320af +2026-08-24-session-log-snapshot-corpus.zh.md: d02c798af23a205993e92ed72a0f3a162f8bdded diff --git a/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.md b/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.md index ebcd9709f9..a9001c2eab 100644 --- a/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.md +++ b/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.md @@ -22,7 +22,7 @@ Fixture decoding and comparison depend only on the selected JSONL content; filen Headless stderr reconstruction expands embedded reasoning from both `assistant/message` and log-only `assistant/attempt` settlements, so failed or retried reasoning remains part of the projected process output. -Each parent or child role uses `session[.][.vN].jsonl`, with v0 encoded by an omitted version and every filename matching its header. Replay, record, and refresh select the numerically highest generation per role. Most owners omit `sessionFormat` and track the current writer; a bounded historical owner declares its exact version and closed coverage names. The v2 corpus keeps selected v0 roles for multi-hop, packed-row, retry/failure, and shipped-profile coverage plus selected v1 roles for the adjacent structural edge. Record and refresh never rewrite an explicitly retained historical fixture, rename a committed generation, or delete one through automatic cleanup. A retained Session generation does not freeze its non-Session expected outputs: refresh still writes owned system-prompt and tool-schema sidecars from the current run. Reviewed source-tree curation removes a predecessor only after the same role has a verified current successor. The corpus policy requires current selected roles to remain the majority and caps historical selected roles at ten; lower predecessor generations may remain beside a selected current successor. +Each parent or child role uses `session[.][.vN].jsonl`, with v0 encoded by an omitted version and every filename matching its header. Replay, record, and refresh select the numerically highest generation per role. Most owners omit `sessionFormat` and track the current writer; a bounded historical owner declares its exact version and closed coverage names. The corpus keeps selected v0 roles for multi-hop, packed-row, retry/failure, and shipped-profile coverage plus selected v1 roles for the v1→v2 structural edge within the complete migration chain. Record and refresh never rewrite an explicitly retained historical fixture, rename a committed generation, or delete one through automatic cleanup. A retained Session generation does not freeze its non-Session expected outputs: refresh still writes owned system-prompt and tool-schema sidecars from the current run. Reviewed source-tree curation removes a predecessor only after the same role has a verified current successor. The corpus policy requires current selected roles to remain the majority and caps historical selected roles at ten; lower predecessor generations may remain beside a selected current successor. Scenario-owned HTTP fixtures separate the stable authority recorded in the session from their transport listener. Each fixture binds loopback port `0`, lets the operating system allocate and bind the port atomically, and maps the recorded URL or endpoint through the real provider to that listener. Any process-global transport interception matches only the recorded endpoint, is owned by the fixture fiber, and is restored before the listener closes. @@ -30,7 +30,7 @@ Every existing ACP scenario receives a behavior-preserving destination. Ordinary Workspace inputs remain scenario-local. A mutating scenario compares a complete expected final workspace that record and refresh never rewrite, so a model or tool self-report cannot satisfy the test. Existing intentional session reuse remains an explicit acyclic owner reference; the corpus adds no workspace inheritance or general fixture-merging mechanism. -Current-writer request-header pins are separate from retained migration inputs: `tool-call-turn` pins the default composition, and `empty-response-retry-current` pins the retry composition. Their readable sidecars remain owned by `text-turn`. The six retained historical inputs stay byte-frozen and selected for replay; their pinned directories contain no canonical V3 sibling that could displace them. Separate `writer.expected.jsonl` and `writer..expected.jsonl` files pin exact normalized native V3 parent and child output, while retained SDK scenarios pin current notifications in `notifications.current.expected.jsonl`. These output oracles are not replay generations. The [snapshot kit](../../../../packages/test-support/session-snapshot/README.md) owns selection and refresh behavior. Structural migration can preserve request meaning without reproducing native writer event layout, so the official migration has independent correctness tests. Reverse projection into historical headers, stripping structural differences, skipping output equality, or replacing frozen inputs would conceal regressions instead of verifying those separate obligations. +Current-writer request-header pins are separate from retained migration inputs: `tool-call-turn` pins the default composition, and `empty-response-retry-current` pins the retry composition. Their readable sidecars remain owned by `text-turn`. The six retained historical inputs stay byte-frozen and selected for replay; their pinned directories contain no newer canonical sibling that could displace them. Separate `writer.expected.jsonl` and `writer..expected.jsonl` files pin exact normalized native current-format parent and child output, while retained SDK scenarios pin current notifications in `notifications.current.expected.jsonl`. These output oracles are not replay generations. The [snapshot kit](../../../../packages/test-support/session-snapshot/README.md) owns selection and refresh behavior. Structural migration can preserve request meaning without reproducing native writer event layout, so the official migration has independent correctness tests. Reverse projection into historical headers, stripping structural differences, skipping output equality, or replacing frozen inputs would conceal regressions instead of verifying those separate obligations. ## Alternatives considered diff --git a/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.zh.md b/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.zh.md index 8d0614e150..d02c798af2 100644 --- a/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.zh.md +++ b/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.zh.md @@ -22,7 +22,7 @@ Fixture 解码与比较只取决于选定 JSONL 内容;文件名标识 invento Headless stderr 重建会同时展开 `assistant/message` 与仅写入日志的 `assistant/attempt` settlement 中嵌入的 reasoning,因此失败或重试尝试的 reasoning 仍属于进程输出投影。 -每个 parent 或 child 角色都使用 `session[.][.vN].jsonl`;v0 省略版本,且每个文件名都与其 header 一致。回放、录制与刷新按角色选择数值最高的 generation。大多数 owner 省略 `sessionFormat` 并跟随当前 writer;受限的历史 owner 会声明精确版本与封闭 coverage 名称。v2 语料保留选定 v0 角色,覆盖多跳、打包行、重试/失败与随附 profile,并保留选定 v1 角色覆盖相邻结构 edge。录制与刷新绝不改写显式保留的历史 fixture、重命名已提交 generation 或通过自动清理删除 generation。保留 Session generation 不会冻结非 Session 预期输出:refresh 仍会根据当前 run 写入 owner 持有的 system-prompt 与 tool-schema sidecar。受审阅的源树整理只有在同角色存在已验证的当前后继后才移除前代。语料策略要求选定当前角色始终占多数,并将选定历史角色上限设为十个;更低的前代 generation 可以保留在选定当前后继旁。 +每个 parent 或 child 角色都使用 `session[.][.vN].jsonl`;v0 省略版本,且每个文件名都与其 header 一致。回放、录制与刷新按角色选择数值最高的 generation。大多数 owner 省略 `sessionFormat` 并跟随当前 writer;受限的历史 owner 会声明精确版本与封闭 coverage 名称。语料保留选定 v0 角色,覆盖多跳、打包行、重试/失败与随附 profile,并保留选定 v1 角色覆盖完整迁移链中的 v1→v2 结构 edge。录制与刷新绝不改写显式保留的历史 fixture、重命名已提交 generation 或通过自动清理删除 generation。保留 Session generation 不会冻结非 Session 预期输出:refresh 仍会根据当前 run 写入 owner 持有的 system-prompt 与 tool-schema sidecar。受审阅的源树整理只有在同角色存在已验证的当前后继后才移除前代。语料策略要求选定当前角色始终占多数,并将选定历史角色上限设为十个;更低的前代 generation 可以保留在选定当前后继旁。 场景拥有的 HTTP fixture 将会话中录制的稳定 authority 与传输 listener 分离。每个 fixture 在回环地址上绑定端口 `0`,由操作系统以一次原子操作分配并绑定端口,再将录制的 URL 或 endpoint 通过真实 provider 映射到该 listener。任何进程全局传输拦截只匹配录制 endpoint,由 fixture fiber 拥有,并在关闭 listener 前恢复。 @@ -30,7 +30,7 @@ Headless stderr 重建会同时展开 `assistant/message` 与仅写入日志的 Workspace 输入继续归各场景本地所有。变更文件的场景比较完整的预期最终 workspace,record 与 refresh 绝不改写该预期,因此模型或工具的自报结果无法满足测试。现有的有意会话复用继续使用显式、无环的所有者引用;语料不增加 workspace 继承或通用 fixture 合并机制。 -当前 writer 的 request-header pin 与保留的迁移输入分离:`tool-call-turn` 固定 default 组合,`empty-response-retry-current` 固定 retry 组合。可读 sidecar 仍由 `text-turn` 持有。六份保留的历史输入保持字节冻结,并继续被选为回放输入;其固定历史版本的目录不含会取代它们的规范 V3 同角色文件。单独的 `writer.expected.jsonl` 与 `writer..expected.jsonl` 文件固定精确的规范化原生 V3 父子会话输出,保留历史输入的 SDK 场景则通过 `notifications.current.expected.jsonl` 固定当前通知。这些输出比较基准不是 replay 代际。[快照工具包](../../../../packages/test-support/session-snapshot/README.zh.md)负责选择与刷新行为。结构迁移可以保留请求含义而不复现原生 writer 的事件布局,因此正式迁移拥有独立的正确性测试。反向投影为历史 header、剥除结构差异、跳过输出相等断言或替换冻结输入都会掩盖回归,而不是验证这些相互独立的约定。 +当前 writer 的 request-header pin 与保留的迁移输入分离:`tool-call-turn` 固定 default 组合,`empty-response-retry-current` 固定 retry 组合。可读 sidecar 仍由 `text-turn` 持有。六份保留的历史输入保持字节冻结,并继续被选为回放输入;其固定历史版本的目录不含会取代它们的更新的规范同角色文件。单独的 `writer.expected.jsonl` 与 `writer..expected.jsonl` 文件固定精确的规范化原生当前格式的父子会话输出,保留历史输入的 SDK 场景则通过 `notifications.current.expected.jsonl` 固定当前通知。这些输出比较基准不是 replay 代际。[快照工具包](../../../../packages/test-support/session-snapshot/README.zh.md)负责选择与刷新行为。结构迁移可以保留请求含义而不复现原生 writer 的事件布局,因此正式迁移拥有独立的正确性测试。反向投影为历史 header、剥除结构差异、跳过输出相等断言或替换冻结输入都会掩盖回归,而不是验证这些相互独立的约定。 ## Alternatives considered 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/CODEOWNERS b/.github/review-ownership/CODEOWNERS deleted file mode 100644 index eccc7e6844..0000000000 --- a/.github/review-ownership/CODEOWNERS +++ /dev/null @@ -1,59 +0,0 @@ -# Custom static-scanner input. Its nested path keeps GitHub from loading it as -# the repository's native CODEOWNERS file. -/apps/cli/ @turtle1999 -/apps/web/ @imccyu -/docs/ @turtle1999 -/native/ @mektpoy -/patches/ @mektpoy -/python/ @LegGasai -/vendor/ @turtle1999 -/website/ @LegGasai -/packages/acp/ @mektpoy -/packages/api/ @imccyu -/packages/attachment/ @CreatixChu -/packages/boot/ @turtle1999 -/packages/bundle/ @turtle1999 -/packages/client/ @imccyu -/packages/code-runtime/ @Chinesezjc -/packages/compaction/ @imccyu -/packages/context/ @turtle1999 -/packages/core/ @turtle1999 @mektpoy -/packages/credentials/ @mektpoy -/packages/e2b/ @mektpoy -/packages/experimental/ @mektpoy -/packages/extensions/ @mektpoy -/packages/feedback/ @mektpoy -/packages/fs/ @mektpoy -/packages/goal/ @mektpoy -/packages/guard/ @turtle1999 -/packages/hooks/ @mektpoy -/packages/host/ @turtle1999 -/packages/identity/ @imccyu -/packages/interaction/ @imccyu -/packages/jobs/ @imccyu -/packages/llm/ @LegGasai -/packages/lsp/ @mektpoy -/packages/mcp/ @mektpoy -/packages/plan/ @mektpoy -/packages/preset/ @LegGasai @turtle1999 -/packages/runtime-diagnostics/ @mektpoy -/packages/sandbox/ @mektpoy -/packages/schedule/ @imccyu -/packages/sdk/ @mektpoy -/packages/session/ @turtle1999 @mektpoy -/packages/session-query/ @mektpoy -/packages/settings/ @mektpoy -/packages/shell/ @mektpoy -/packages/skill/ @mektpoy -/packages/spill/ @mektpoy -/packages/storage/ @imccyu -/packages/subagent/ @Dudu-0223 -/packages/subprocess/ @mektpoy -/packages/terminal/ @imccyu -/packages/todo/ @mektpoy -/packages/typert/ @imccyu -/packages/util/ @mektpoy -/packages/web/ @imccyu -/packages/webhook/ @mektpoy -/packages/workflow/ @mektpoy -/packages/workspace/ @imccyu diff --git a/.github/review-ownership/README.md b/.github/review-ownership/README.md index 3616b9bba6..609b260d90 100644 --- a/.github/review-ownership/README.md +++ b/.github/review-ownership/README.md @@ -1,39 +1,21 @@ -# Automated pull-request reviews +# Pull-request approval policy ## Summary -The [`request-review` workflow](../workflows/request-review.yml) requests owners for reviewable code. The [`weighted-approval` workflow](../workflows/weighted-approval.yml) publishes an approval score for branch rules. Both write-capable workflows execute policy from the trusted default branch. +The [`weighted-approval` workflow](../workflows/weighted-approval.yml) publishes an approval score for branch rules. Reviewer selection and review requests remain manual. ## Table of Contents -- [Routing](#routing) - [Approval scoring](#approval-scoring) -- [Review exclusions](#review-exclusions) - [Security](#security) - [Verification](#verification) - [Dev Note](#dev-note) - - -## Routing - -Pull requests run the workflow when opened, synchronized, reopened, marked ready for review, or converted to a draft. The scanner fetches the complete pull-request file list, evaluates both paths of a rename, and fails instead of routing from a partial list. GitHub exposes at most 3,000 files for this API. - -For a non-draft pull request, the workflow keeps at most one current individual review request other than `@turtle1999`; an existing request for `@turtle1999` does not consume that slot. Each run adds at most one reviewer. An existing non-turtle request leaves no slot, so the workflow does not add anyone, including `@turtle1999`. Existing individual requests consume the slot even when made by people outside the ownership map. When more candidates remain than the available counted slot can cover, the workflow ranks them by the total GitHub-reported additions plus deletions in reviewable changed-file records that match each owner. A rename contributes its changed LOC once to an owner even when both paths match that owner. Higher changed LOC ranks first, and login order resolves ties. - -Before selecting a new reviewer, a non-draft run fetches the pull request's complete chronological review list. An owner's latest undismissed decisive review is `APPROVED` or `CHANGES_REQUESTED`; comments and pending reviews do not replace that decision. An approved owner remains omitted after later synchronize events, while a later changes-requested review makes the owner eligible again. The workflow fails before mutation when the list reaches the supported 3,000-review limit or contains an invalid record. - -On every run with current review requests, the workflow reads the pull-request timeline. A current reviewer is workflow-authored only when the latest matching `review_requested` event names `github-actions[bot]` as `review_requester`; a request without an attributable event is preserved. On a non-draft pull request, the workflow cancels workflow-authored reviewers that no longer match the current candidates and excess workflow-authored non-turtle reviewers above the counted limit. Current relevance order decides which matching workflow reviewer remains when the limit shrinks. It then fills any slot left by the planned cancellations. On a draft, it cancels every current workflow-authored request. Requests made by people remain unchanged in both states. An attributable event with invalid provenance fails before mutation, and the workflow also fails without cancellation when the timeline exceeds 3,000 events. - -The ownership map accepts explicit absolute directory patterns and one or two individual GitHub users per pattern. It rejects wildcards, hidden-directory patterns, teams, more than two owners, and duplicate patterns or owners. Matching follows CODEOWNERS last-match semantics. The scanner prints the changed code, excluded test, documentation, and comment-only files; per-file owner matches and LOC; the aggregate owner relevance ranking; approved owners omitted from new requests; current individual requests and the available counted slot after planned cancellations; and the reviewers it will request or cancel before it mutates review requests. Unmatched files remain visible in the log. The pull-request author, approved owners, and users who remain requested are omitted from new requests. - -The policy test measures non-test tracked lines under matched directories and requires `@turtle1999` to own no more than one third of that eligible owned codebase. - ## 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. @@ -41,34 +23,22 @@ Each reviewer contributes only the current `APPROVED` or `CHANGES_REQUESTED` dec The publisher runs when a pull request opens, synchronizes, reopens, becomes ready, or becomes a draft. Review submissions, edits, and dismissals run the no-permission [`weighted-approval-review-event` workflow](../workflows/weighted-approval-review-event.yml); its validated run title supplies the pull-request number to the default-branch publisher. The publisher validates the current head, fetches every review, and resolves current repository permission before publishing the status. Permission changes take effect on the next subscribed pull-request or review event. - - -## Review exclusions - -Review routing excludes the repository's unit, end-to-end, expected-output, snapshot, benchmark, performance, stress, corpus, native, and Python test conventions. This includes `test`, `tests`, `__tests__`, `__snapshots__`, `benches`, and `stress-tests` directories; the top-level `benchmarks` and `snapshots` trees; `packages/test-support`; `scripts/fixtures` and `scripts/snapshots`; recognized test filename suffixes; and Python `test_*.py` or `*_test.py` files. - -Test infrastructure that can alter how evidence is produced remains reviewable, including `vitest*.config.ts` and gate implementations under `scripts`. A production file named `test.ts`, `spec.ts`, or `snapshot.ts` is not excluded solely by that name. - -Files ending in `.md` or `.yaml`, with case-insensitive extension matching, are documentation and never contribute owners. A `.yml` file remains reviewable unless another exclusion applies. - -For a modified file with a supported source extension, the scanner compares the pre-change and post-change text after removing parsed comments. It excludes the file only when GitHub supplies a patch whose counted additions and deletions prove that the patch is complete and the remaining code is identical. The parser recognizes C-style line and block comments, hash comments, SQL comments, CSS block comments, and HTML comments for their declared extensions. Renames, unsupported languages, missing or partial patches, and uncertain comment forms remain reviewable. - ## Security -The write-capable jobs check out only the repository default branch. They do not check out or execute pull-request code and do not use repository secrets. The review-event workflow has no `GITHUB_TOKEN` permissions and passes only a decimal pull-request number in its run title. The publisher rejects an invalid run title and a number that does not resolve to the workflow run's current pull-request head. Pull-request filenames and reviews are treated as API data and escaped in logs. +The status-writing job checks out only the repository default branch. It does not check out or execute pull-request code and does not use repository secrets. The review-event workflow has no `GITHUB_TOKEN` permissions and passes only a decimal pull-request number in its run title. The publisher rejects an invalid run title and a number that does not resolve to the workflow run's current pull-request head. Pull-request reviews are treated as API data and escaped in logs. -Ownership and approval policy changes take effect only after they merge into the default branch. This prevents an untrusted pull request from changing either program or policy for its own run. +Approval policy changes take effect only after they merge into the default branch. This prevents an untrusted pull request from changing the program or policy for its own run. ## Verification -Run `pnpm run test:request-review` for ownership parsing, file classification, complete-patch checks, comment parsing, changed-LOC ranking, pagination, approval-state reduction, logging order, non-draft reconciliation, draft cancellation, reviewer provenance, reviewer filtering, and API behavior. Run `pnpm run test:approval-policy` for policy parsing, effective review decisions, review-event validation, pagination, permission filtering, weighted scoring, blockers, drafts, status publication, and API failures. [Workflow tests](../../scripts/ci-workflow.spec.ts) pin the trusted checkout, no-permission review handoff, permissions, events, and commands. The repository gate graph runs both policy checks and the workflow tests in CI. +Run `pnpm run test:approval-policy` for policy parsing, effective review decisions, review-event validation, pagination, permission filtering, weighted scoring, blockers, drafts, status publication, and API failures. [Workflow tests](../../scripts/ci-workflow.spec.ts) pin the trusted checkout, no-permission review handoff, permissions, events, and commands. The repository gate graph runs the approval policy and workflow tests in CI. ## Dev Note -The [review-routing decision](../../.agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.md) records the security model, test exclusions, and alternatives. +None. 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/review-ownership/request-review.mjs b/.github/review-ownership/request-review.mjs deleted file mode 100644 index c2d1ee7f43..0000000000 --- a/.github/review-ownership/request-review.mjs +++ /dev/null @@ -1,660 +0,0 @@ -#!/usr/bin/env node - -import { readFileSync } from 'node:fs' -import process from 'node:process' -import { pathToFileURL } from 'node:url' - -const API_VERSION = '2026-03-10' -const MAX_OWNERS_PER_RULE = 2 -const MAX_PULL_REQUEST_FILES = 3_000 -const MAX_PULL_REQUEST_REVIEWS = 3_000 -const MAX_COUNTED_REQUESTED_REVIEWERS = 1 -const MAX_TIMELINE_EVENTS = 3_000 -const PAGE_SIZE = 100 -const PULL_REQUEST_REVIEW_STATES = new Set(['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED', 'DISMISSED', 'PENDING']) -const UNCOUNTED_REVIEWER = 'turtle1999' -const WORKFLOW_REVIEW_REQUESTER = 'github-actions[bot]' -const TEST_DIRECTORY_NAMES = new Set(['__snapshots__', '__tests__', 'benches', 'stress-tests', 'test', 'tests']) -const TEST_FILE_MARKER = /\.(?:bench|corpus|e2e|perf|snapshot|spec|stress|test)\.[^./]+$/u -const PYTHON_TEST_FILE = /^(?:test_.+|.+_tests?)\.py$/u -const DOCUMENTATION_FILE = /\.(?:md|yaml)$/iu -const C_STYLE_EXTENSIONS = new Set([ - 'c', 'cc', 'cjs', 'cpp', 'cts', 'cxx', 'go', 'h', 'hpp', 'java', 'js', 'jsx', - 'kt', 'kts', 'less', 'mjs', 'mts', 'rs', 'scss', 'swift', 'ts', 'tsx', -]) -const BLOCK_COMMENT_EXTENSIONS = new Set(['css']) -const HASH_COMMENT_EXTENSIONS = new Set(['bash', 'ps1', 'py', 'pyi', 'r', 'rb', 'sh', 'toml', 'yml', 'zsh']) -const HTML_COMMENT_EXTENSIONS = new Set(['htm', 'html']) - -/** - * Parse the explicit directory subset accepted from the review ownership file. - * @param {string} source CODEOWNERS-compatible source text. - * @returns {Array<{pattern: string, prefix: string, owners: string[]}>} Ordered ownership rules. - */ -export function parseOwnership(source) { - const rules = [] - const patterns = new Set() - for (const [index, rawLine] of source.split('\n').entries()) { - const line = rawLine.trim() - if (!line || line.startsWith('#')) continue - const [pattern, ...owners] = line.split(/\s+/u) - const location = `ownership line ${index + 1}` - if (!/^\/[^*?[\]#!\\]+\/$/u.test(pattern)) { - throw new Error(`${location}: expected one explicit absolute directory pattern`) - } - if (pattern.startsWith('/.')) throw new Error(`${location}: hidden-directory patterns are not allowed`) - if (patterns.has(pattern)) throw new Error(`${location}: duplicate pattern ${JSON.stringify(pattern)}`) - if (owners.length === 0) throw new Error(`${location}: expected at least one owner`) - if (owners.length > MAX_OWNERS_PER_RULE) { - throw new Error(`${location}: expected at most ${MAX_OWNERS_PER_RULE} owners`) - } - const normalizedOwners = [] - const seenOwners = new Set() - for (const owner of owners) { - if (!/^@[A-Za-z0-9-]+$/u.test(owner)) { - throw new Error(`${location}: only individual GitHub users are supported`) - } - const key = owner.toLowerCase() - if (seenOwners.has(key)) throw new Error(`${location}: duplicate owner ${owner}`) - seenOwners.add(key) - normalizedOwners.push(owner) - } - patterns.add(pattern) - rules.push({ pattern, prefix: pattern.slice(1), owners: normalizedOwners }) - } - if (rules.length === 0) throw new Error('ownership file contains no rules') - return rules -} - -/** - * Normalize a repository-relative path received from GitHub. - * @param {unknown} value GitHub file path. - * @returns {string} Slash-normalized repository path. - */ -export function normalizeRepositoryPath(value) { - if (typeof value !== 'string' || value.length === 0) throw new Error('changed file has no path') - const normalized = value.replaceAll('\\', '/').replace(/^\.\/+/, '') - if ( - normalized.startsWith('/') - || normalized.includes('\0') - || normalized.split('/').some(segment => !segment || segment === '.' || segment === '..') - ) { - throw new Error(`invalid repository path ${JSON.stringify(value)}`) - } - return normalized -} - -/** - * Decide whether a repository path belongs only to test evidence or test support. - * @param {string} value Repository-relative path. - * @returns {boolean} Whether reviewer routing must ignore the path. - */ -export function isTestPath(value) { - const file = normalizeRepositoryPath(value) - const segments = file.split('/') - if (segments[0] === 'benchmarks' || segments[0] === 'snapshots') return true - if (segments[0] === 'packages' && segments[1] === 'test-support') return true - if (segments[0] === 'scripts' && (segments[1] === 'fixtures' || segments[1] === 'snapshots')) return true - if (segments.some(segment => TEST_DIRECTORY_NAMES.has(segment))) return true - const basename = segments.at(-1) ?? '' - return TEST_FILE_MARKER.test(basename) || PYTHON_TEST_FILE.test(basename) -} - -/** - * Decide whether a repository path is documentation excluded from review routing. - * @param {string} value Repository-relative path. - * @returns {boolean} Whether the path has an excluded documentation extension. - */ -export function isDocumentationPath(value) { - return DOCUMENTATION_FILE.test(normalizeRepositoryPath(value)) -} - -/** - * Decide whether a complete modified-file patch changes comments only. - * @param {unknown} value GitHub changed-file record. - * @returns {boolean} Whether supported comment parsing removes every changed token. - */ -export function isCommentOnlyChange(value) { - if (!isRecord(value) || value.status !== 'modified' || typeof value.filename !== 'string' - || typeof value.patch !== 'string' || !Number.isSafeInteger(value.additions) - || value.additions < 0 || !Number.isSafeInteger(value.deletions) || value.deletions < 0) return false - const syntax = commentSyntax(value.filename) - if (syntax === undefined) return false - if (value.filename.toLowerCase().endsWith('.rs') && /\b(?:br|r)#{0,255}"/u.test(value.patch)) return false - const hunks = parsePatchHunks(value.patch) - if (hunks === undefined || hunks.additions !== value.additions || hunks.deletions !== value.deletions) { - return false - } - return hunks.values.every(({ before, after }) => - normalizedCode(before, syntax) === normalizedCode(after, syntax)) -} - -function commentSyntax(filename) { - const normalized = normalizeRepositoryPath(filename) - const basename = normalized.slice(normalized.lastIndexOf('/') + 1).toLowerCase() - const extension = basename.includes('.') ? basename.slice(basename.lastIndexOf('.') + 1) : '' - const line = [] - const block = [] - if (C_STYLE_EXTENSIONS.has(extension)) { - line.push('//') - block.push(['/*', '*/']) - } - if (BLOCK_COMMENT_EXTENSIONS.has(extension)) block.push(['/*', '*/']) - if (HASH_COMMENT_EXTENSIONS.has(extension) || basename === 'dockerfile' || basename.startsWith('dockerfile.') - || basename === 'makefile' || basename.startsWith('makefile.')) line.push('#') - if (extension === 'sql') { - line.push('--') - block.push(['/*', '*/']) - } - if (HTML_COMMENT_EXTENSIONS.has(extension)) block.push(['']) - return line.length === 0 && block.length === 0 ? undefined : { line, block } -} - -function parsePatchHunks(patch) { - const values = [] - let current - let additions = 0 - let deletions = 0 - for (const line of patch.split('\n')) { - if (line.startsWith('@@')) { - current = { before: [], after: [] } - values.push(current) - continue - } - if (current === undefined || line.startsWith('\\ No newline at end of file')) continue - const prefix = line[0] - const content = line.slice(1) - if (prefix === ' ') { - current.before.push(content) - current.after.push(content) - } else if (prefix === '-') { - current.before.push(content) - deletions++ - } else if (prefix === '+') { - current.after.push(content) - additions++ - } - } - return values.length === 0 ? undefined : { values, additions, deletions } -} - -function normalizedCode(lines, syntax) { - return stripComments(lines.join('\n'), syntax) - .split('\n') - .map(line => line.trimEnd()) - .filter(line => line.trim().length > 0) - .join('\n') -} - -function stripComments(source, syntax) { - let result = '' - let quote - let blockEnd - for (let index = 0; index < source.length;) { - if (blockEnd !== undefined) { - if (source.startsWith(blockEnd, index)) { - index += blockEnd.length - blockEnd = undefined - } else { - index++ - } - continue - } - const character = source[index] - if (quote !== undefined) { - result += character - index++ - if (character === '\\' && index < source.length) { - result += source[index] - index++ - } else if (character === quote) { - quote = undefined - } - continue - } - if (character === '\'' || character === '"' || character === '`') { - quote = character - result += character - index++ - continue - } - const block = syntax.block.find(([start]) => source.startsWith(start, index)) - if (block !== undefined) { - index += block[0].length - blockEnd = block[1] - continue - } - const line = syntax.line.find(marker => source.startsWith(marker, index)) - const lineStart = index === 0 || source[index - 1] === '\n' - const hashStartsComment = line !== '#' || lineStart || /\s/u.test(source[index - 1] ?? '') - if (line !== undefined && hashStartsComment && !(line === '#' && lineStart && source[index + 1] === '!')) { - const newline = source.indexOf('\n', index + line.length) - if (newline === -1) break - result += '\n' - index = newline + 1 - continue - } - result += character - index++ - } - return result -} - -/** - * Expand changed-file records into reviewable, test, documentation, and comment-only paths. - * @param {unknown[]} files Pull-request file records from GitHub. - * @returns {{changedCodeFiles: string[], reviewableChanges: Array<{paths: string[], changedLines: number}>, excludedTestFiles: string[], excludedDocumentationFiles: string[], excludedCommentOnlyFiles: string[]}} Classified paths and their GitHub-reported changed-line counts. - */ -export function classifyChangedFiles(files) { - const changedCodeFiles = new Set() - const reviewableChanges = [] - const excludedTestFiles = new Set() - const excludedDocumentationFiles = new Set() - const excludedCommentOnlyFiles = new Set() - for (const entry of files) { - if (!isRecord(entry)) throw new Error('changed-file response contains a non-object entry') - const changedLines = changedLineCount(entry) - const paths = [normalizeRepositoryPath(entry.filename)] - const commentOnly = isCommentOnlyChange(entry) - if (entry.previous_filename !== undefined) { - paths.unshift(normalizeRepositoryPath(entry.previous_filename)) - } - const reviewablePaths = [] - for (const file of new Set(paths)) { - if (isTestPath(file)) excludedTestFiles.add(file) - else if (isDocumentationPath(file)) excludedDocumentationFiles.add(file) - else if (commentOnly) excludedCommentOnlyFiles.add(file) - else { - changedCodeFiles.add(file) - reviewablePaths.push(file) - } - } - if (reviewablePaths.length > 0) { - reviewableChanges.push({ paths: reviewablePaths.sort(), changedLines }) - } - } - return { - changedCodeFiles: [...changedCodeFiles].sort(), - reviewableChanges, - excludedTestFiles: [...excludedTestFiles].sort(), - excludedDocumentationFiles: [...excludedDocumentationFiles].sort(), - excludedCommentOnlyFiles: [...excludedCommentOnlyFiles].sort(), - } -} - -function changedLineCount(entry) { - for (const field of ['additions', 'deletions']) { - if (!Number.isSafeInteger(entry[field]) || entry[field] < 0) { - throw new Error(`changed-file ${field} must be a non-negative integer`) - } - } - const changedLines = entry.additions + entry.deletions - if (!Number.isSafeInteger(changedLines)) throw new Error('changed-file LOC exceeds the safe integer range') - return changedLines -} - -/** - * Match changed paths and rank owners by their reviewable changed LOC. - * @param {Array<{prefix: string, owners: string[]}>} rules Ordered ownership rules. - * @param {Array<{paths: string[], changedLines: number}>} reviewableChanges Reviewable GitHub file records. - * @returns {{matches: Array<{file: string, changedLines: number, owners: string[]}>, reviewers: Array<{login: string, changedLines: number}>}} Routing plan. - */ -export function planReviewers(rules, reviewableChanges) { - const matches = [] - const reviewers = new Map() - for (const change of reviewableChanges) { - const changeOwners = new Map() - for (const file of change.paths) { - let owners = [] - for (const rule of rules) { - if (file.startsWith(rule.prefix)) owners = rule.owners - } - matches.push({ file, changedLines: change.changedLines, owners }) - for (const owner of owners) changeOwners.set(owner.toLowerCase(), owner.slice(1)) - } - for (const [key, login] of changeOwners) { - const changedLines = (reviewers.get(key)?.changedLines ?? 0) + change.changedLines - if (!Number.isSafeInteger(changedLines)) throw new Error(`changed LOC for @${login} exceeds the safe integer range`) - reviewers.set(key, { login, changedLines }) - } - } - return { - matches: matches.sort((left, right) => left.file.localeCompare(right.file, 'en')), - reviewers: [...reviewers.values()].sort((left, right) => { - if (left.changedLines !== right.changedLines) return left.changedLines < right.changedLines ? 1 : -1 - return left.login.localeCompare(right.login, 'en') - }), - } -} - -/** - * Create a repository-scoped GitHub JSON API caller. - * @param {{token: string, apiUrl?: string, fetchImpl?: typeof fetch}} options API dependencies. - * @returns {(path: string, options?: {method?: string, body?: unknown}) => Promise} API caller. - */ -export function createGitHubApi({ token, apiUrl = 'https://api.github.com', fetchImpl = globalThis.fetch }) { - if (!token) throw new Error('GITHUB_TOKEN is not set') - if (typeof fetchImpl !== 'function') throw new Error('fetch is unavailable') - const root = apiUrl.replace(/\/+$/u, '') - return async (path, { method = 'GET', body } = {}) => { - const response = await fetchImpl(`${root}${path}`, { - method, - headers: { - Accept: 'application/vnd.github+json', - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - 'User-Agent': 'deepseek-harness-request-review', - 'X-GitHub-Api-Version': API_VERSION, - }, - ...(body === undefined ? {} : { body: JSON.stringify(body) }), - }) - if (!response.ok) { - const responseBody = await response.text() - throw new Error(`GitHub API ${method} ${path} returned ${response.status}: ${JSON.stringify(responseBody)}`) - } - if (response.status === 204) return undefined - return response.json() - } -} - -/** - * Fetch the complete pull-request file list or fail before routing a partial list. - * @param {(path: string, options?: {method?: string, body?: unknown}) => Promise} api GitHub API caller. - * @param {string} repository Owner/name repository identifier. - * @param {number} pullNumber Pull-request number. - * @param {number} expectedCount Pull-request changed-file count. - * @returns {Promise} Complete changed-file records. - */ -export async function listPullRequestFiles(api, repository, pullNumber, expectedCount) { - if (!Number.isSafeInteger(expectedCount) || expectedCount < 0) { - throw new Error('pull request changed_files must be a non-negative integer') - } - if (expectedCount > MAX_PULL_REQUEST_FILES) { - throw new Error(`pull request has ${expectedCount} files; GitHub exposes at most ${MAX_PULL_REQUEST_FILES}`) - } - const files = [] - for (let page = 1; files.length < expectedCount; page++) { - const response = await api(`/repos/${repository}/pulls/${pullNumber}/files?per_page=${PAGE_SIZE}&page=${page}`) - if (!Array.isArray(response) || response.length === 0) { - throw new Error(`GitHub returned ${files.length} of ${expectedCount} changed files`) - } - files.push(...response) - if (files.length > expectedCount) { - throw new Error(`GitHub returned ${files.length} files but the pull request reports ${expectedCount}`) - } - } - return files -} - -/** - * Fetch the complete chronological pull-request review list. - * @param {(path: string, options?: {method?: string, body?: unknown}) => Promise} api GitHub API caller. - * @param {string} repository Owner/name repository identifier. - * @param {number} pullNumber Pull-request number. - * @returns {Promise} Complete review list within the supported limit. - */ -export async function listPullRequestReviews(api, repository, pullNumber) { - const reviews = [] - for (let page = 1; ; page++) { - const response = await api(`/repos/${repository}/pulls/${pullNumber}/reviews?per_page=${PAGE_SIZE}&page=${page}`) - if (!Array.isArray(response)) throw new Error('pull-request reviews response is not an array') - reviews.push(...response) - if (response.length < PAGE_SIZE) return reviews - if (reviews.length >= MAX_PULL_REQUEST_REVIEWS) { - throw new Error(`pull-request reviews exceed ${MAX_PULL_REQUEST_REVIEWS} entries`) - } - } -} - -/** - * Return users whose latest undismissed decisive review approves the pull request. - * @param {unknown[]} reviews Chronological GitHub pull-request review records. - * @returns {string[]} Approved reviewer logins in stable order. - */ -export function approvedReviewerLogins(reviews) { - const approved = new Map() - for (const review of reviews) { - if (!isRecord(review) || !isRecord(review.user) || typeof review.user.login !== 'string') { - throw new Error('pull-request reviews response contains an invalid reviewer') - } - if (typeof review.state !== 'string' || !PULL_REQUEST_REVIEW_STATES.has(review.state)) { - throw new Error('pull-request reviews response contains an invalid state') - } - const key = review.user.login.toLowerCase() - if (review.state === 'APPROVED') approved.set(key, review.user.login) - else if (review.state === 'CHANGES_REQUESTED') approved.delete(key) - } - return [...approved.values()].sort((left, right) => left.localeCompare(right, 'en')) -} - -/** - * Fetch the pull request timeline used to identify workflow-authored review requests. - * @param {(path: string, options?: {method?: string, body?: unknown}) => Promise} api GitHub API caller. - * @param {string} repository Owner/name repository identifier. - * @param {number} pullNumber Pull-request number. - * @returns {Promise} Complete timeline event list within the supported limit. - */ -export async function listPullRequestTimeline(api, repository, pullNumber) { - const events = [] - for (let page = 1; ; page++) { - const response = await api(`/repos/${repository}/issues/${pullNumber}/timeline?per_page=${PAGE_SIZE}&page=${page}`) - if (!Array.isArray(response)) throw new Error('pull-request timeline response is not an array') - events.push(...response) - if (response.length < PAGE_SIZE) return events - if (events.length >= MAX_TIMELINE_EVENTS) { - throw new Error(`pull-request timeline exceeds ${MAX_TIMELINE_EVENTS} events`) - } - } -} - -/** Return current requested reviewers whose latest request came from this workflow identity. */ -function workflowRequestedReviewers(events, requestedReviewers) { - const requested = new Map(requestedReviewers.map(login => [login.toLowerCase(), login])) - const latestRequester = new Map() - for (const event of events) { - if (!isRecord(event) || event.event !== 'review_requested') continue - if (!isRecord(event.requested_reviewer) || typeof event.requested_reviewer.login !== 'string') continue - const key = event.requested_reviewer.login.toLowerCase() - if (!requested.has(key)) continue - if (!isRecord(event.review_requester) || typeof event.review_requester.login !== 'string') { - throw new Error('review-request timeline event has no requester login') - } - latestRequester.set(key, event.review_requester.login.toLowerCase()) - } - return [...requested] - .filter(([key]) => latestRequester.get(key) === WORKFLOW_REVIEW_REQUESTER) - .map(([, login]) => login) -} - -/** Extract and validate individual logins from GitHub's requested-reviewer response. */ -function requestedReviewerLogins(response) { - if (!isRecord(response) || !Array.isArray(response.users)) { - throw new Error('requested-reviewers response has no users array') - } - return response.users.map((user) => { - if (!isRecord(user) || typeof user.login !== 'string') { - throw new Error('requested-reviewers response contains an invalid user') - } - return user.login - }) -} - -/** - * Print changed paths, reconcile workflow-authored requests with current - * ownership, and cancel workflow-authored requests on drafts. - * @param {{event: unknown, ownershipSource: string, api: (path: string, options?: {method?: string, body?: unknown}) => Promise, write?: (line: string) => void}} options Runtime inputs. - * @returns {Promise<{changedCodeFiles: string[], excludedTestFiles: string[], excludedDocumentationFiles: string[], excludedCommentOnlyFiles: string[], requestedReviewers: string[], cancelledReviewers: string[]}>} Applied routing result. - */ -export async function requestReviews({ event, ownershipSource, api, write = line => process.stdout.write(`${line}\n`) }) { - const pull = pullRequestFromEvent(event) - write('This is by automated Angry Turtle Cyborg, not a human') - const files = await listPullRequestFiles(api, pull.repository, pull.number, pull.changedFileCount) - const { reviewableChanges, ...classified } = classifyChangedFiles(files) - const plan = planReviewers(parseOwnership(ownershipSource), reviewableChanges) - writeList(write, 'Changed code files', classified.changedCodeFiles.map(file => JSON.stringify(file))) - writeList(write, 'Excluded test files', classified.excludedTestFiles.map(file => JSON.stringify(file))) - writeList( - write, - 'Excluded documentation files', - classified.excludedDocumentationFiles.map(file => JSON.stringify(file)), - ) - writeList( - write, - 'Excluded comment-only files', - classified.excludedCommentOnlyFiles.map(file => JSON.stringify(file)), - ) - writeList( - write, - 'Owners by changed file', - plan.matches.map(({ file, changedLines, owners }) => - `${JSON.stringify(file)} (${changedLines} LOC): ${owners.length ? owners.join(' ') : '(none)'}`), - ) - writeList( - write, - 'Owner relevance by changed LOC', - plan.reviewers.map(({ login, changedLines }) => `@${login}: ${changedLines}`), - ) - - const ownerCandidates = plan.reviewers.filter(({ login }) => login.toLowerCase() !== pull.author.toLowerCase()) - if (pull.draft) { - const existing = await api(`/repos/${pull.repository}/pulls/${pull.number}/requested_reviewers`) - const requestedReviewers = requestedReviewerLogins(existing) - const reviewers = requestedReviewers.length === 0 - ? [] - : workflowRequestedReviewers( - await listPullRequestTimeline(api, pull.repository, pull.number), - requestedReviewers, - ) - writeList(write, 'Review requests to cancel', reviewers.map(login => `@${login}`)) - if (reviewers.length === 0) return { ...classified, requestedReviewers: [], cancelledReviewers: [] } - - await api(`/repos/${pull.repository}/pulls/${pull.number}/requested_reviewers`, { - method: 'DELETE', - body: { reviewers }, - }) - const requestLabel = reviewers.length === 1 ? 'request' : 'requests' - write(`Cancelled review ${requestLabel} for ${reviewers.map(login => `@${login}`).join(' ')}.`) - return { ...classified, requestedReviewers: [], cancelledReviewers: reviewers } - } - - const approvedReviewerKeys = new Set( - (ownerCandidates.length === 0 - ? [] - : approvedReviewerLogins(await listPullRequestReviews(api, pull.repository, pull.number))) - .map(login => login.toLowerCase()), - ) - const approvedOwners = ownerCandidates.filter(({ login }) => approvedReviewerKeys.has(login.toLowerCase())) - const candidates = ownerCandidates.filter(({ login }) => !approvedReviewerKeys.has(login.toLowerCase())) - writeList(write, 'Approved owners omitted from review requests', approvedOwners.map(({ login }) => `@${login}`)) - - const existing = await api(`/repos/${pull.repository}/pulls/${pull.number}/requested_reviewers`) - const currentReviewers = requestedReviewerLogins(existing).sort((left, right) => left.localeCompare(right, 'en')) - const workflowReviewers = currentReviewers.length === 0 - ? [] - : workflowRequestedReviewers( - await listPullRequestTimeline(api, pull.repository, pull.number), - currentReviewers, - ) - const workflowReviewerKeys = new Set(workflowReviewers.map(login => login.toLowerCase())) - const manualReviewers = currentReviewers.filter(login => !workflowReviewerKeys.has(login.toLowerCase())) - let retainedCountedSlots = Math.max( - 0, - MAX_COUNTED_REQUESTED_REVIEWERS - - manualReviewers.filter(login => login.toLowerCase() !== UNCOUNTED_REVIEWER).length, - ) - const retainedWorkflowReviewerKeys = new Set() - for (const { login } of candidates) { - const key = login.toLowerCase() - if (!workflowReviewerKeys.has(key)) continue - if (key === UNCOUNTED_REVIEWER) retainedWorkflowReviewerKeys.add(key) - else if (retainedCountedSlots > 0) { - retainedWorkflowReviewerKeys.add(key) - retainedCountedSlots-- - } - } - const reviewersToCancel = workflowReviewers.filter( - login => !retainedWorkflowReviewerKeys.has(login.toLowerCase()), - ) - const cancelledReviewerKeys = new Set(reviewersToCancel.map(login => login.toLowerCase())) - const remainingReviewers = currentReviewers.filter(login => !cancelledReviewerKeys.has(login.toLowerCase())) - const alreadyRequested = new Set(remainingReviewers.map(login => login.toLowerCase())) - const availableSlots = Math.max( - 0, - MAX_COUNTED_REQUESTED_REVIEWERS - - remainingReviewers.filter(login => login.toLowerCase() !== UNCOUNTED_REVIEWER).length, - ) - writeList(write, 'Current individual review requests', currentReviewers.map(login => `@${login}`)) - write(`Available counted review request slots: ${availableSlots}.`) - const reviewers = candidates - .filter(({ login }) => !alreadyRequested.has(login.toLowerCase())) - .slice(0, availableSlots) - .map(({ login }) => login) - writeList(write, 'Review requests to cancel', reviewersToCancel.map(login => `@${login}`)) - writeList(write, 'Reviewers to request', reviewers.map(login => `@${login}`)) - if (reviewersToCancel.length > 0) { - await api(`/repos/${pull.repository}/pulls/${pull.number}/requested_reviewers`, { - method: 'DELETE', - body: { reviewers: reviewersToCancel }, - }) - const requestLabel = reviewersToCancel.length === 1 ? 'request' : 'requests' - write(`Cancelled review ${requestLabel} for ${reviewersToCancel.map(login => `@${login}`).join(' ')}.`) - } - - if (reviewers.length > 0) { - await api(`/repos/${pull.repository}/pulls/${pull.number}/requested_reviewers`, { - method: 'POST', - body: { reviewers }, - }) - write(`Requested ${reviewers.map(login => `@${login}`).join(' ')}.`) - } - return { ...classified, requestedReviewers: reviewers, cancelledReviewers: reviewersToCancel } -} - -function pullRequestFromEvent(event) { - if (!isRecord(event) || !isRecord(event.repository) || typeof event.repository.full_name !== 'string') { - throw new Error('event has no repository.full_name') - } - if (!isRecord(event.pull_request) || !isRecord(event.pull_request.user)) { - throw new Error('event has no pull_request') - } - const { pull_request: pull } = event - if (!Number.isSafeInteger(pull.number) || pull.number <= 0) throw new Error('pull request has no valid number') - if (typeof pull.draft !== 'boolean') throw new Error('pull request has no draft flag') - if (typeof pull.user.login !== 'string' || !pull.user.login) throw new Error('pull request has no author login') - return { - repository: event.repository.full_name, - number: pull.number, - draft: pull.draft, - author: pull.user.login, - changedFileCount: pull.changed_files, - } -} - -function writeList(write, title, entries) { - write(`${title}:`) - if (entries.length === 0) write('- (none)') - else for (const entry of entries) write(`- ${entry}`) -} - -function isRecord(value) { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -async function main() { - const eventPath = process.env.GITHUB_EVENT_PATH - if (!eventPath) throw new Error('GITHUB_EVENT_PATH is not set') - const event = JSON.parse(readFileSync(eventPath, 'utf8')) - const ownershipSource = readFileSync(new URL('CODEOWNERS', import.meta.url), 'utf8') - const api = createGitHubApi({ - token: process.env.GITHUB_TOKEN ?? '', - apiUrl: process.env.GITHUB_API_URL, - }) - await requestReviews({ event, ownershipSource, api }) -} - -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - main().catch((error) => { - process.stderr.write(`request-review failed: ${error instanceof Error ? error.message : String(error)}\n`) - process.exitCode = 1 - }) -} diff --git a/.github/review-ownership/request-review.test.mjs b/.github/review-ownership/request-review.test.mjs deleted file mode 100644 index fdb3d763a4..0000000000 --- a/.github/review-ownership/request-review.test.mjs +++ /dev/null @@ -1,869 +0,0 @@ -import assert from 'node:assert/strict' -import { execFileSync } from 'node:child_process' -import { existsSync, readFileSync } from 'node:fs' -import test from 'node:test' - -import { - approvedReviewerLogins, - classifyChangedFiles, - createGitHubApi, - isCommentOnlyChange, - isDocumentationPath, - isTestPath, - listPullRequestFiles, - listPullRequestReviews, - listPullRequestTimeline, - normalizeRepositoryPath, - parseOwnership, - planReviewers, - requestReviews, -} from './request-review.mjs' - -const ownershipSource = readFileSync(new URL('CODEOWNERS', import.meta.url), 'utf8') - -const pullRequestEvent = ({ author = 'author', changedFiles = 1, draft = false } = {}) => ({ - repository: { full_name: 'deepseek-harness/deepseek-harness' }, - pull_request: { - number: 42, - draft, - changed_files: changedFiles, - user: { login: author }, - }, -}) - -test('loads the repository ownership policy without test-only directory rules', () => { - const rules = parseOwnership(ownershipSource) - const ownersByPattern = new Map(rules.map(rule => [rule.pattern, rule.owners])) - assert.equal(rules.length, 57) - assert.equal(rules.some(rule => rule.pattern === '/benchmarks/'), false) - assert.equal(rules.some(rule => rule.pattern === '/scripts/'), false) - assert.equal(rules.some(rule => rule.pattern === '/snapshots/'), false) - assert.equal(rules.some(rule => rule.pattern === '/packages/test-support/'), false) - assert.deepEqual(ownersByPattern.get('/apps/cli/'), ['@turtle1999']) - assert.deepEqual(ownersByPattern.get('/docs/'), ['@turtle1999']) - assert.deepEqual(ownersByPattern.get('/packages/core/'), ['@turtle1999', '@mektpoy']) - assert.deepEqual(ownersByPattern.get('/packages/llm/'), ['@LegGasai']) - assert.deepEqual(ownersByPattern.get('/packages/preset/'), ['@LegGasai', '@turtle1999']) - assert.deepEqual(ownersByPattern.get('/packages/session/'), ['@turtle1999', '@mektpoy']) - assert.deepEqual(ownersByPattern.get('/packages/subagent/'), ['@Dudu-0223']) - assert.deepEqual(ownersByPattern.get('/packages/web/'), ['@imccyu']) - assert.deepEqual(ownersByPattern.get('/python/'), ['@LegGasai']) - assert.deepEqual(ownersByPattern.get('/website/'), ['@LegGasai']) - assert.equal(rules.every(rule => rule.owners.length <= 2), true) - for (const excludedOwner of ['@tianyicui', '@kermeanx', '@pkh-xht']) { - assert.equal(rules.some(rule => rule.owners.some(owner => owner.toLowerCase() === excludedOwner)), false) - } -}) - -test('keeps turtle below one third of the eligible owned codebase', () => { - const rules = parseOwnership(ownershipSource) - const trackedFiles = execFileSync('git', ['ls-files', '-z'], { encoding: 'utf8' }) - .split('\0') - .filter(file => file && existsSync(file)) - let ownedLines = 0 - let turtleLines = 0 - for (const file of trackedFiles) { - if (isTestPath(file) || isDocumentationPath(file)) continue - const owners = planReviewers(rules, [{ paths: [file], changedLines: 0 }]).matches[0]?.owners ?? [] - if (owners.length === 0) continue - const content = readFileSync(file) - const lines = content.length === 0 - ? 0 - : content.reduce((count, byte) => count + (byte === 10 ? 1 : 0), 0) + (content.at(-1) === 10 ? 0 : 1) - ownedLines += lines - if (owners.includes('@turtle1999')) turtleLines += lines - } - assert.ok( - turtleLines * 3 <= ownedLines, - `@turtle1999 owns ${turtleLines} of ${ownedLines} eligible owned lines`, - ) -}) - -test('rejects ownership forms the requester cannot apply safely', () => { - for (const [source, message] of [ - ['', /contains no rules/u], - ['* @owner\n', /explicit absolute directory/u], - ['/.github/ @owner\n', /hidden-directory/u], - ['/packages/*/ @owner\n', /explicit absolute directory/u], - ['/packages/core/\n', /at least one owner/u], - ['/packages/core/ @org/team\n', /individual GitHub users/u], - ['/packages/core/ @one @two @three\n', /at most 2 owners/u], - ['/packages/core/ @owner @OWNER\n', /duplicate owner/u], - ['/packages/core/ @owner\n/packages/core/ @other\n', /duplicate pattern/u], - ]) { - assert.throws(() => parseOwnership(source), message) - } -}) - -test('recognizes every repository test location and filename convention', () => { - for (const file of [ - 'apps/cli/tests/args.spec.ts', - 'apps/cli/tests/harness.ts', - 'apps/web/stress-tests/reasoning-chunks.stress.ts', - 'benchmarks/session-open/workload.ts', - 'native/landlock-run/test/entry.test.js', - 'packages/core/agent/__tests__/agent.ts', - 'packages/core/agent/benches/agent.rs', - 'packages/core/agent/src/agent.compat.spec.ts', - 'packages/core/agent/src/__snapshots__/agent.ts.snap', - 'packages/session-query/session-query/tests/test-service.ts', - 'packages/test-support/session-snapshot/src/index.ts', - 'python/sdk/src/test_client.py', - 'python/sdk/src/client_test.py', - 'scripts/fixtures/translation-prompt/response.txt', - 'scripts/session-snapshot-corpus.corpus.ts', - 'scripts/snapshots/translation-prompt-v4/request-response.expected.json', - 'snapshots/session/headless.snapshot.ts', - ]) { - assert.equal(isTestPath(file), true, file) - } -}) - -test('does not confuse production names with tests', () => { - for (const file of [ - 'apps/cli/src/testing.ts', - 'packages/core/agent/src/contest.ts', - 'packages/session/session-format/src/snapshot.ts', - 'packages/session/session-format/src/spec.ts', - 'packages/session/session-format/src/test.ts', - 'scripts/run-gates.ts', - 'vitest.config.ts', - 'vitest.bench.config.ts', - 'vitest.e2e.config.ts', - 'vitest.snapshot.config.ts', - 'vitest.web.perf.config.ts', - 'website/docs.ts', - ]) { - assert.equal(isTestPath(file), false, file) - } -}) - -test('excludes Markdown and YAML documentation extensions', () => { - for (const file of [ - 'README.md', - 'docs/architecture.MD', - 'packages/subagent/subagent/guide.yaml', - 'profiles/example.YAML', - ]) { - assert.equal(isDocumentationPath(file), true, file) - } - for (const file of [ - '.github/workflows/request-review.yml', - 'packages/subagent/subagent/src/index.ts', - 'website/docs.ts', - ]) { - assert.equal(isDocumentationPath(file), false, file) - } -}) - -test('detects comment-only changes only from complete supported patches', () => { - for (const file of [ - { - filename: 'packages/core/agent/src/index.ts', - status: 'modified', additions: 1, deletions: 1, - patch: '@@ -1,2 +1,2 @@\n-// old note\n+// new note\n const value = "https://example.com"', - }, - { - filename: 'python/sdk/src/client.py', - status: 'modified', additions: 1, deletions: 1, - patch: '@@ -1 +1 @@\n-value = 1 # old note\n+value = 1 # new note', - }, - { - filename: 'native/landlock-run/src/main.rs', - status: 'modified', additions: 1, deletions: 1, - patch: '@@ -1 +1 @@\n-let value = 1; /* old note */\n+let value = 1; /* new note */', - }, - ]) { - assert.equal(isCommentOnlyChange(file), true, file.filename) - } - - for (const file of [ - { - filename: 'packages/core/agent/src/index.ts', - status: 'modified', additions: 1, deletions: 1, - patch: '@@ -1 +1 @@\n-const value = 1 // note\n+const value = 2 // note', - }, - { - filename: 'packages/core/agent/src/index.ts', - status: 'modified', additions: 2, deletions: 1, - patch: '@@ -1 +1 @@\n-// old note\n+// new note', - }, - { - filename: 'packages/core/agent/src/data.json', - status: 'modified', additions: 1, deletions: 1, - patch: '@@ -1 +1 @@\n-{"value":1}\n+{"value":2}', - }, - { - filename: 'native/landlock-run/src/main.rs', - status: 'modified', additions: 1, deletions: 1, - patch: '@@ -1 +1 @@\n-let value = r#"https://old.example"#;\n+let value = r#"https://new.example"#;', - }, - { - filename: 'packages/core/agent/src/index.ts', - status: 'renamed', additions: 1, deletions: 1, - patch: '@@ -1 +1 @@\n-// old note\n+// new note', - }, - ]) { - assert.equal(isCommentOnlyChange(file), false, file.filename) - } -}) - -test('normalizes separators and rejects paths that are not repository-relative', () => { - assert.equal(normalizeRepositoryPath('./packages\\core\\agent\\src\\index.ts'), 'packages/core/agent/src/index.ts') - for (const file of ['', '/absolute.ts', '../escape.ts', 'packages//empty.ts', 'packages/./same.ts']) { - assert.throws(() => normalizeRepositoryPath(file), /path/u, file) - } -}) - -test('classifies both sides of a rename independently', () => { - assert.deepEqual( - classifyChangedFiles([ - { - filename: 'packages/core/agent/tests/moved.spec.ts', - previous_filename: 'packages/core/agent/src/moved.ts', - additions: 3, - deletions: 2, - }, - { - filename: 'packages/client/store/src/restored.ts', - previous_filename: 'packages/client/store/tests/restored.spec.ts', - additions: 2, - deletions: 1, - }, - { filename: 'packages/core/agent/README.md', additions: 1, deletions: 0 }, - { - filename: 'packages/core/agent/src/commented.ts', - status: 'modified', additions: 1, deletions: 1, - patch: '@@ -1 +1 @@\n-// old note\n+// new note', - }, - ]), - { - changedCodeFiles: [ - 'packages/client/store/src/restored.ts', - 'packages/core/agent/src/moved.ts', - ], - reviewableChanges: [ - { paths: ['packages/core/agent/src/moved.ts'], changedLines: 5 }, - { paths: ['packages/client/store/src/restored.ts'], changedLines: 3 }, - ], - excludedTestFiles: [ - 'packages/client/store/tests/restored.spec.ts', - 'packages/core/agent/tests/moved.spec.ts', - ], - excludedDocumentationFiles: ['packages/core/agent/README.md'], - excludedCommentOnlyFiles: ['packages/core/agent/src/commented.ts'], - }, - ) -}) - -test('uses the last matching ownership rule and ranks owners by changed LOC', () => { - const rules = parseOwnership('/packages/ @broad\n/packages/core/ @core @second\n') - assert.deepEqual( - planReviewers(rules, [ - { paths: ['AGENTS.md'], changedLines: 1 }, - { paths: ['packages/core/agent/src/index.ts'], changedLines: 8 }, - { paths: ['packages/fs/fs/src/index.ts'], changedLines: 3 }, - ]), - { - matches: [ - { file: 'AGENTS.md', changedLines: 1, owners: [] }, - { file: 'packages/core/agent/src/index.ts', changedLines: 8, owners: ['@core', '@second'] }, - { file: 'packages/fs/fs/src/index.ts', changedLines: 3, owners: ['@broad'] }, - ], - reviewers: [ - { login: 'core', changedLines: 8 }, - { login: 'second', changedLines: 8 }, - { login: 'broad', changedLines: 3 }, - ], - }, - ) -}) - -test('counts each changed-file record once per owner across rename paths', () => { - const rules = parseOwnership('/packages/a/ @same @a\n/packages/b/ @same @b\n/packages/c/ @c\n') - const plan = planReviewers(rules, [ - { paths: ['packages/a/old.ts', 'packages/b/new.ts'], changedLines: 10 }, - { paths: ['packages/a/other.ts'], changedLines: 5 }, - { paths: ['packages/c/tiny.ts'], changedLines: 1 }, - ]) - assert.deepEqual(plan.reviewers, [ - { login: 'a', changedLines: 15 }, - { login: 'same', changedLines: 15 }, - { login: 'b', changedLines: 10 }, - { login: 'c', changedLines: 1 }, - ]) -}) - -test('rejects invalid changed-file LOC', () => { - for (const file of [ - { filename: 'packages/core/index.ts', deletions: 0 }, - { filename: 'packages/core/index.ts', additions: -1, deletions: 0 }, - { filename: 'packages/core/index.ts', additions: Number.MAX_SAFE_INTEGER, deletions: 1 }, - ]) { - assert.throws(() => classifyChangedFiles([file]), /changed-file|LOC/u) - } -}) - -test('fetches every declared changed file across pages', async () => { - const calls = [] - const pageOne = Array.from({ length: 100 }, (_, index) => ({ filename: `packages/core/file-${index}.ts` })) - const pageTwo = [{ filename: 'packages/core/file-100.ts' }] - const api = async (path) => { - calls.push(path) - return calls.length === 1 ? pageOne : pageTwo - } - const files = await listPullRequestFiles(api, 'owner/repo', 42, 101) - assert.equal(files.length, 101) - assert.deepEqual(calls, [ - '/repos/owner/repo/pulls/42/files?per_page=100&page=1', - '/repos/owner/repo/pulls/42/files?per_page=100&page=2', - ]) -}) - -test('fails closed when GitHub cannot provide the complete file list', async () => { - let calls = 0 - await assert.rejects( - listPullRequestFiles(async () => { - calls++ - return calls === 1 ? [{ filename: 'one.ts' }] : [] - }, 'owner/repo', 42, 2), - /returned 1 of 2/u, - ) - await assert.rejects( - listPullRequestFiles(async () => [], 'owner/repo', 42, 3_001), - /at most 3000/u, - ) -}) - -test('fetches pull-request reviews across pages', async () => { - const calls = [] - const pageOne = Array.from({ length: 100 }, (_, index) => ({ - user: { login: `reviewer-${index}` }, - state: 'COMMENTED', - })) - const pageTwo = [{ user: { login: 'approver' }, state: 'APPROVED' }] - const reviews = await listPullRequestReviews(async (path) => { - calls.push(path) - return calls.length === 1 ? pageOne : pageTwo - }, 'owner/repo', 42) - - assert.equal(reviews.length, 101) - assert.deepEqual(calls, [ - '/repos/owner/repo/pulls/42/reviews?per_page=100&page=1', - '/repos/owner/repo/pulls/42/reviews?per_page=100&page=2', - ]) -}) - -test('tracks each reviewer\'s latest undismissed approval decision', () => { - assert.deepEqual(approvedReviewerLogins([ - { user: { login: 'commented-after' }, state: 'APPROVED' }, - { user: { login: 'commented-after' }, state: 'COMMENTED' }, - { user: { login: 'changes-after' }, state: 'APPROVED' }, - { user: { login: 'changes-after' }, state: 'CHANGES_REQUESTED' }, - { user: { login: 'dismissed' }, state: 'DISMISSED' }, - { user: { login: 'approved-after' }, state: 'CHANGES_REQUESTED' }, - { user: { login: 'approved-after' }, state: 'APPROVED' }, - { user: { login: 'pending-after' }, state: 'APPROVED' }, - { user: { login: 'pending-after' }, state: 'PENDING' }, - ]), ['approved-after', 'commented-after', 'pending-after']) - - assert.throws( - () => approvedReviewerLogins([{ user: { login: 'reviewer' }, state: 'UNKNOWN' }]), - /invalid state/u, - ) - assert.throws(() => approvedReviewerLogins([{ state: 'APPROVED' }]), /invalid reviewer/u) -}) - -test('fails closed when the pull-request review list exceeds its limit', async () => { - let calls = 0 - await assert.rejects( - listPullRequestReviews(async () => { - calls++ - return Array.from({ length: 100 }, () => ({ user: { login: 'reviewer' }, state: 'COMMENTED' })) - }, 'owner/repo', 42), - /exceed 3000 entries/u, - ) - assert.equal(calls, 30) -}) - -test('fails closed when the review-request timeline exceeds its limit', async () => { - let calls = 0 - await assert.rejects( - listPullRequestTimeline(async () => { - calls++ - return Array.from({ length: 100 }, () => ({ event: 'commented' })) - }, 'owner/repo', 42), - /exceeds 3000 events/u, - ) - assert.equal(calls, 30) -}) - -test('prints changed code files and requests the highest-ranked counted owner', async () => { - const trace = [] - const files = [ - { filename: 'packages/core/agent/src/index.ts', additions: 70, deletions: 10 }, - { filename: 'packages/preset/agent-presets/src/index.ts', additions: 5, deletions: 5 }, - { filename: 'packages/client/store/src/index.ts', additions: 2, deletions: 0 }, - { filename: 'packages/subagent/subagent/src/index.ts', additions: 40, deletions: 0 }, - { filename: 'packages/core/agent/tests/index.spec.ts', additions: 100, deletions: 0 }, - { filename: 'AGENTS.md', additions: 200, deletions: 0 }, - ] - const api = async (path, options = {}) => { - trace.push({ type: 'api', path, options }) - if (path.endsWith('/files?per_page=100&page=1')) return files - if (path.endsWith('/reviews?per_page=100&page=1')) return [] - if (path.endsWith('/requested_reviewers') && options.method !== 'POST') { - return { users: [], teams: [] } - } - if (path.endsWith('/requested_reviewers') && options.method === 'POST') return {} - throw new Error(`unexpected API path ${path}`) - } - - const result = await requestReviews({ - event: pullRequestEvent({ author: 'turtle1999', changedFiles: files.length }), - ownershipSource, - api, - write: line => trace.push({ type: 'log', line }), - }) - - assert.deepEqual(result, { - changedCodeFiles: [ - 'packages/client/store/src/index.ts', - 'packages/core/agent/src/index.ts', - 'packages/preset/agent-presets/src/index.ts', - 'packages/subagent/subagent/src/index.ts', - ], - excludedTestFiles: ['packages/core/agent/tests/index.spec.ts'], - excludedDocumentationFiles: ['AGENTS.md'], - excludedCommentOnlyFiles: [], - requestedReviewers: ['mektpoy'], - cancelledReviewers: [], - }) - assert.equal(trace[0].type, 'log') - assert.equal(trace[0].line, 'This is by automated Angry Turtle Cyborg, not a human') - const changedHeading = trace.findIndex(item => item.type === 'log' && item.line === 'Changed code files:') - const relevanceHeading = trace.findIndex(item => item.type === 'log' && item.line === 'Owner relevance by changed LOC:') - const post = trace.findIndex(item => item.type === 'api' && item.options.method === 'POST') - assert.ok(changedHeading >= 0 && changedHeading < relevanceHeading && relevanceHeading < post) - assert.deepEqual(trace.slice(relevanceHeading, relevanceHeading + 6).map(item => item.line), [ - 'Owner relevance by changed LOC:', - '- @turtle1999: 90', - '- @mektpoy: 80', - '- @Dudu-0223: 40', - '- @LegGasai: 10', - '- @imccyu: 2', - ]) - assert.deepEqual(trace[post], { - type: 'api', - path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers', - options: { - method: 'POST', - body: { reviewers: ['mektpoy'] }, - }, - }) -}) - -test('does not request an owner again after that owner approves', async () => { - const calls = [] - const output = [] - const result = await requestReviews({ - event: pullRequestEvent(), - ownershipSource: '/packages/typert/ @imccyu\n', - api: async (path, options = {}) => { - calls.push({ path, options }) - if (path.endsWith('/files?per_page=100&page=1')) { - return [{ filename: 'packages/typert/generator/src/analyzer.ts', additions: 150, deletions: 47 }] - } - if (path.endsWith('/reviews?per_page=100&page=1')) { - return [ - { user: { login: 'imccyu' }, state: 'APPROVED' }, - { user: { login: 'imccyu' }, state: 'COMMENTED' }, - ] - } - if (path.endsWith('/requested_reviewers') && options.method === undefined) { - return { users: [], teams: [] } - } - throw new Error(`unexpected API path ${path}`) - }, - write: line => output.push(line), - }) - - assert.deepEqual(result.requestedReviewers, []) - assert.equal(calls.some(call => call.options.method === 'POST'), false) - const approvedHeading = output.indexOf('Approved owners omitted from review requests:') - assert.ok(approvedHeading >= 0) - assert.equal(output[approvedHeading + 1], '- @imccyu') -}) - -test('fills the counted slot with the next owner after omitting an approved owner', async () => { - const calls = [] - const result = await requestReviews({ - event: pullRequestEvent(), - ownershipSource: '/packages/core/ @imccyu @mektpoy\n', - api: async (path, options = {}) => { - calls.push({ path, options }) - if (path.endsWith('/files?per_page=100&page=1')) { - return [{ filename: 'packages/core/agent/src/index.ts', additions: 20, deletions: 10 }] - } - if (path.endsWith('/reviews?per_page=100&page=1')) { - return [{ user: { login: 'imccyu' }, state: 'APPROVED' }] - } - if (path.endsWith('/requested_reviewers') && options.method === undefined) { - return { users: [], teams: [] } - } - if (path.endsWith('/requested_reviewers') && options.method === 'POST') return {} - throw new Error(`unexpected API path ${path}`) - }, - write: () => {}, - }) - - assert.deepEqual(result.requestedReviewers, ['mektpoy']) - assert.deepEqual(calls.find(call => call.options.method === 'POST'), { - path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers', - options: { method: 'POST', body: { reviewers: ['mektpoy'] } }, - }) -}) - -test('does not add another counted owner when one is already requested', async () => { - const calls = [] - const output = [] - const result = await requestReviews({ - event: pullRequestEvent(), - ownershipSource: '/packages/core/ @mektpoy\n', - api: async (path, options = {}) => { - calls.push({ path, options }) - if (path.endsWith('/files?per_page=100&page=1')) { - return [{ filename: 'packages/core/agent/src/index.ts', additions: 20, deletions: 10 }] - } - if (path.endsWith('/reviews?per_page=100&page=1')) return [] - if (path.endsWith('/requested_reviewers') && options.method === undefined) { - return { users: [{ login: 'first' }], teams: [] } - } - if (path.endsWith('/timeline?per_page=100&page=1')) return [] - throw new Error(`unexpected API path ${path}`) - }, - write: line => output.push(line), - }) - - assert.deepEqual(result.requestedReviewers, []) - assert.equal(calls.some(call => call.options.method === 'POST'), false) - assert.deepEqual(output.slice(-7), [ - 'Current individual review requests:', - '- @first', - 'Available counted review request slots: 0.', - 'Review requests to cancel:', - '- (none)', - 'Reviewers to request:', - '- (none)', - ]) -}) - -test('requests at most one owner per run when turtle ranks first', async () => { - const calls = [] - const result = await requestReviews({ - event: pullRequestEvent({ author: 'contributor', changedFiles: 2 }), - ownershipSource: '/packages/core/ @turtle1999\n/packages/client/ @mektpoy\n', - api: async (path, options = {}) => { - calls.push({ path, options }) - if (path.endsWith('/files?per_page=100&page=1')) { - return [ - { filename: 'packages/core/agent/src/index.ts', additions: 25, deletions: 5 }, - { filename: 'packages/client/store/src/index.ts', additions: 8, deletions: 2 }, - ] - } - if (path.endsWith('/reviews?per_page=100&page=1')) return [] - if (path.endsWith('/requested_reviewers') && options.method === undefined) { - return { users: [], teams: [] } - } - if (path.endsWith('/requested_reviewers') && options.method === 'POST') return {} - throw new Error(`unexpected API path ${path}`) - }, - write: () => {}, - }) - - assert.deepEqual(result.requestedReviewers, ['turtle1999']) - assert.deepEqual(calls.find(call => call.options.method === 'POST'), { - path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers', - options: { method: 'POST', body: { reviewers: ['turtle1999'] } }, - }) -}) - -test('does not add turtle when one counted reviewer is already requested', async () => { - const calls = [] - const result = await requestReviews({ - event: pullRequestEvent({ author: 'contributor' }), - ownershipSource: '/packages/core/ @turtle1999 @mektpoy\n', - api: async (path, options = {}) => { - calls.push({ path, options }) - if (path.endsWith('/files?per_page=100&page=1')) { - return [{ filename: 'packages/core/agent/src/index.ts', additions: 20, deletions: 10 }] - } - if (path.endsWith('/reviews?per_page=100&page=1')) return [] - if (path.endsWith('/requested_reviewers') && options.method === undefined) { - return { users: [{ login: 'first' }], teams: [] } - } - if (path.endsWith('/timeline?per_page=100&page=1')) return [] - throw new Error(`unexpected API path ${path}`) - }, - write: () => {}, - }) - - assert.deepEqual(result.requestedReviewers, []) - assert.equal(calls.some(call => call.options.method === 'POST'), false) -}) - -test('keeps the counted slot available when turtle is already requested', async () => { - const calls = [] - const result = await requestReviews({ - event: pullRequestEvent({ author: 'contributor' }), - ownershipSource: '/packages/core/ @turtle1999 @mektpoy\n', - api: async (path, options = {}) => { - calls.push({ path, options }) - if (path.endsWith('/files?per_page=100&page=1')) { - return [{ filename: 'packages/core/agent/src/index.ts', additions: 20, deletions: 10 }] - } - if (path.endsWith('/reviews?per_page=100&page=1')) return [] - if (path.endsWith('/requested_reviewers') && options.method === undefined) { - return { users: [{ login: 'turtle1999' }], teams: [] } - } - if (path.endsWith('/timeline?per_page=100&page=1')) return [] - if (path.endsWith('/requested_reviewers') && options.method === 'POST') return {} - throw new Error(`unexpected API path ${path}`) - }, - write: () => {}, - }) - - assert.deepEqual(result.requestedReviewers, ['mektpoy']) - assert.deepEqual(calls.find(call => call.options.method === 'POST'), { - path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers', - options: { method: 'POST', body: { reviewers: ['mektpoy'] } }, - }) -}) - -test('replaces a workflow reviewer that no longer matches current ownership', async () => { - const trace = [] - const result = await requestReviews({ - event: pullRequestEvent({ author: 'contributor' }), - ownershipSource: '/packages/core/ @mektpoy\n', - api: async (path, options = {}) => { - trace.push({ type: 'api', path, options }) - if (path.endsWith('/files?per_page=100&page=1')) { - return [{ filename: 'packages/core/agent/src/index.ts', additions: 20, deletions: 10 }] - } - if (path.endsWith('/reviews?per_page=100&page=1')) return [] - if (path.endsWith('/requested_reviewers') && options.method === undefined) { - return { users: [{ login: 'Dudu-0223' }], teams: [] } - } - if (path.endsWith('/timeline?per_page=100&page=1')) { - return [{ - event: 'review_requested', - requested_reviewer: { login: 'Dudu-0223' }, - review_requester: { login: 'github-actions[bot]' }, - }] - } - if (path.endsWith('/requested_reviewers') && options.method === 'DELETE') return {} - if (path.endsWith('/requested_reviewers') && options.method === 'POST') return {} - throw new Error(`unexpected API path ${path}`) - }, - write: line => trace.push({ type: 'log', line }), - }) - - assert.deepEqual(result.requestedReviewers, ['mektpoy']) - assert.deepEqual(result.cancelledReviewers, ['Dudu-0223']) - const cancelLog = trace.findIndex(item => item.type === 'log' && item.line === 'Review requests to cancel:') - const requestLog = trace.findIndex(item => item.type === 'log' && item.line === 'Reviewers to request:') - const firstMutation = trace.findIndex(item => item.type === 'api' && item.options.method !== undefined) - assert.ok(cancelLog >= 0 && requestLog >= 0 && cancelLog < firstMutation && requestLog < firstMutation) - assert.equal(trace[cancelLog + 1].line, '- @Dudu-0223') - assert.equal(trace[requestLog + 1].line, '- @mektpoy') - assert.deepEqual(trace.filter(item => item.type === 'api' && item.options.method !== undefined), [ - { - type: 'api', - path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers', - options: { method: 'DELETE', body: { reviewers: ['Dudu-0223'] } }, - }, - { - type: 'api', - path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers', - options: { method: 'POST', body: { reviewers: ['mektpoy'] } }, - }, - ]) -}) - -test('removes excess workflow reviewers using current relevance order', async () => { - const calls = [] - const result = await requestReviews({ - event: pullRequestEvent({ author: 'contributor', changedFiles: 2 }), - ownershipSource: '/packages/core/ @mektpoy\n/packages/subagent/ @Dudu-0223\n', - api: async (path, options = {}) => { - calls.push({ path, options }) - if (path.endsWith('/files?per_page=100&page=1')) { - return [ - { filename: 'packages/core/agent/src/index.ts', additions: 25, deletions: 5 }, - { filename: 'packages/subagent/subagent/src/index.ts', additions: 8, deletions: 2 }, - ] - } - if (path.endsWith('/reviews?per_page=100&page=1')) return [] - if (path.endsWith('/requested_reviewers') && options.method === undefined) { - return { users: [{ login: 'Dudu-0223' }, { login: 'mektpoy' }], teams: [] } - } - if (path.endsWith('/timeline?per_page=100&page=1')) { - return ['Dudu-0223', 'mektpoy'].map(login => ({ - event: 'review_requested', - requested_reviewer: { login }, - review_requester: { login: 'github-actions[bot]' }, - })) - } - if (path.endsWith('/requested_reviewers') && options.method === 'DELETE') return {} - throw new Error(`unexpected API path ${path}`) - }, - write: () => {}, - }) - - assert.deepEqual(result, { - changedCodeFiles: [ - 'packages/core/agent/src/index.ts', - 'packages/subagent/subagent/src/index.ts', - ], - excludedTestFiles: [], - excludedDocumentationFiles: [], - excludedCommentOnlyFiles: [], - requestedReviewers: [], - cancelledReviewers: ['Dudu-0223'], - }) - assert.deepEqual(calls.find(call => call.options.method === 'DELETE'), { - path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers', - options: { method: 'DELETE', body: { reviewers: ['Dudu-0223'] } }, - }) -}) - -test('does not request reviewers for test, documentation, or comment-only changes', async () => { - const calls = [] - const output = [] - const files = [ - { filename: 'apps/web/tests/chat.e2e.ts', additions: 10, deletions: 0 }, - { filename: 'packages/core/agent/tests/agent.spec.ts', additions: 10, deletions: 0 }, - { filename: 'packages/core/agent/README.md', additions: 10, deletions: 0 }, - { filename: 'packages/core/agent/examples.yaml', additions: 10, deletions: 0 }, - { - filename: 'packages/core/agent/src/index.ts', - status: 'modified', additions: 1, deletions: 1, - patch: '@@ -1 +1 @@\n-// old note\n+// new note', - }, - ] - const result = await requestReviews({ - event: pullRequestEvent({ changedFiles: files.length }), - ownershipSource, - api: async (path) => { - calls.push(path) - if (path.endsWith('/files?per_page=100&page=1')) return files - if (path.endsWith('/requested_reviewers')) return { users: [], teams: [] } - throw new Error(`unexpected API path ${path}`) - }, - write: line => output.push(line), - }) - assert.deepEqual(result, { - changedCodeFiles: [], - excludedTestFiles: files.slice(0, 2).map(file => file.filename), - excludedDocumentationFiles: files.slice(2, 4).map(file => file.filename), - excludedCommentOnlyFiles: ['packages/core/agent/src/index.ts'], - requestedReviewers: [], - cancelledReviewers: [], - }) - assert.equal(calls.length, 2) - assert.deepEqual(output.slice(0, 4), [ - 'This is by automated Angry Turtle Cyborg, not a human', - 'Changed code files:', - '- (none)', - 'Excluded test files:', - ]) -}) - -test('cancels workflow-authored review requests on draft pull requests', async () => { - const trace = [] - const files = [ - { filename: 'packages/subagent/subagent/src/index.ts', additions: 10, deletions: 2 }, - { filename: 'packages/subagent/subagent/tests/index.spec.ts', additions: 10, deletions: 0 }, - { filename: 'packages/subagent/subagent/README.md', additions: 10, deletions: 0 }, - ] - const result = await requestReviews({ - event: pullRequestEvent({ draft: true, changedFiles: files.length }), - ownershipSource, - api: async (path, options = {}) => { - trace.push({ type: 'api', path, options }) - if (path.endsWith('/files?per_page=100&page=1')) return files - if (path.endsWith('/requested_reviewers') && options.method === undefined) { - return { users: [{ login: 'Dudu-0223' }, { login: 'manual-reviewer' }], teams: [] } - } - if (path.endsWith('/timeline?per_page=100&page=1')) { - return [ - { - event: 'review_requested', - requested_reviewer: { login: 'Dudu-0223' }, - review_requester: { login: 'maintainer' }, - }, - { - event: 'review_requested', - requested_reviewer: { login: 'Dudu-0223' }, - review_requester: { login: 'github-actions[bot]' }, - }, - { - event: 'review_requested', - requested_reviewer: { login: 'manual-reviewer' }, - review_requester: { login: 'github-actions[bot]' }, - }, - { - event: 'review_requested', - requested_reviewer: { login: 'manual-reviewer' }, - review_requester: { login: 'maintainer' }, - }, - ] - } - if (path.endsWith('/requested_reviewers') && options.method === 'DELETE') return {} - throw new Error(`unexpected API path ${path}`) - }, - write: line => trace.push({ type: 'log', line }), - }) - assert.deepEqual(result, { - changedCodeFiles: ['packages/subagent/subagent/src/index.ts'], - excludedTestFiles: ['packages/subagent/subagent/tests/index.spec.ts'], - excludedDocumentationFiles: ['packages/subagent/subagent/README.md'], - excludedCommentOnlyFiles: [], - requestedReviewers: [], - cancelledReviewers: ['Dudu-0223'], - }) - const remove = trace.find(item => item.type === 'api' && item.options.method === 'DELETE') - assert.deepEqual(remove, { - type: 'api', - path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers', - options: { method: 'DELETE', body: { reviewers: ['Dudu-0223'] } }, - }) - assert.equal(trace.some(item => item.type === 'log' && item.line === '- @manual-reviewer'), false) - assert.equal(trace.at(-1).line, 'Cancelled review request for @Dudu-0223.') -}) - -test('sends authenticated JSON and escapes an API error body', async () => { - const requests = [] - const api = createGitHubApi({ - token: 'secret', - apiUrl: 'https://github.example/api/v3/', - fetchImpl: async (url, options) => { - requests.push({ url, options }) - return new Response(JSON.stringify({ ok: true }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - }, - }) - assert.deepEqual(await api('/repos/owner/repo', { method: 'POST', body: { value: 1 } }), { ok: true }) - assert.equal(requests[0].url, 'https://github.example/api/v3/repos/owner/repo') - assert.equal(requests[0].options.headers.Authorization, 'Bearer secret') - assert.equal(requests[0].options.headers['X-GitHub-Api-Version'], '2026-03-10') - assert.equal(requests[0].options.body, '{"value":1}') - - const failing = createGitHubApi({ - token: 'secret', - fetchImpl: async () => new Response('::error::untrusted\nbody', { status: 422 }), - }) - await assert.rejects(failing('/failure'), /"::error::untrusted\\nbody"/u) -}) diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index ae7dd61554..49866ba5ea 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -46,7 +46,8 @@ concurrency: # github.workflow identifies the caller inside a reusable workflow and keeps # an ordinary CI run from cancelling a full release validation on the same ref. group: build-single-exe-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: ${{ github.event_name != 'push' || github.ref != 'refs/heads/master' }} + # Release-owned builds are part of an intentional publication transaction. + cancel-in-progress: ${{ !inputs.release }} permissions: contents: read diff --git a/.github/workflows/ci-master.yml b/.github/workflows/ci-master.yml index df9f7f2bcc..dd861115d4 100644 --- a/.github/workflows/ci-master.yml +++ b/.github/workflows/ci-master.yml @@ -14,14 +14,11 @@ on: - larger-runner-benchmark - consolidated-runner-benchmark -# Master runs platform runtime checks, Wine, and two self-hosted standby drills. -# The drills outlast the interval between master merges, so -# push is exempt from cancellation (see ci-failover-runbook). workflow_dispatch -# keeps cancelling: a re-dispatched runner benchmark holds up to 12 larger -# runners for 15 minutes in this same group. +# New master pushes and manual runs replace obsolete checks in this workflow/ref. +# Standby drills share cancellation; use completed runs as readiness evidence. concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: ${{ github.event_name != 'push' }} + cancel-in-progress: true permissions: contents: read diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e05b444a4..e7cff1454e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -562,7 +562,7 @@ jobs: # Coverage flakes must not prevent the cache from building; the # measured durations remain useful even when a partition failed. The # per-run key keeps every save a fresh immutable cache entry. - if: always() + if: ${{ !cancelled() }} uses: actions/cache/save@v4 with: path: .coverage-times.json @@ -666,10 +666,8 @@ jobs: # `needs`. Native Windows build and process checks are required; Wine and # the deferred Python runtime targets live in ci-master.yml and do not # participate in this PR verdict. `needs` cannot cross workflow files. - # `if: always()` is load-bearing: without it a failed dependency - # would SKIP this job, and GitHub counts a skipped required check as passing - # — so this job always runs and fails on any non-success result, including - # 'cancelled' and 'skipped'. + # An explicit status function runs the verdict after failed/skipped needs + # without keeping a cancelled workflow alive for an obsolete verdict. all-checks-passed: name: all checks passed # This bookkeeping-only verdict must not depend on custom-pool @@ -684,7 +682,7 @@ jobs: && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') || 'ubuntu-latest' }} needs: [node-24, node-24-coverage, node-24-bench, node-24-consumers, node-compat, python-sdk, python-runtime, windows-build, windows-native-tests] - if: always() && github.event_name == 'pull_request' + if: ${{ !cancelled() && github.event_name == 'pull_request' }} steps: - name: Fail if any needed job did not succeed if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 099908616c..3b7ff878f8 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -36,11 +36,11 @@ on: # 00:17 UTC nightly = 08:17 Asia/Shanghai — off the top-of-hour cron stampede. - cron: '17 0 * * *' -# Cancel a superseded PR run (it is on a stale commit); never cancel a -# push/schedule run — it is already producing the post-merge/nightly signal. +# Keep only the newest validation in each workflow/ref, including master +# pushes, nightly runs, and manual dispatches. concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} + cancel-in-progress: true # Least privilege: this job only reads the repo to run tests. permissions: diff --git a/.github/workflows/release-vendor.yml b/.github/workflows/release-vendor.yml index ddf3c3f96c..75fb865767 100644 --- a/.github/workflows/release-vendor.yml +++ b/.github/workflows/release-vendor.yml @@ -20,7 +20,7 @@ permissions: concurrency: # Pack runs per ref so concurrent pull requests never displace each other. group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: false + cancel-in-progress: true env: PRIMARY_NODE_VERSION: '24' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1415d1f9e9..633186c8f1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,7 @@ permissions: concurrency: # Pack runs per ref so concurrent pull requests never displace each other. group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: false + cancel-in-progress: true env: PRIMARY_NODE_VERSION: '24' diff --git a/.github/workflows/request-review.yml b/.github/workflows/request-review.yml deleted file mode 100644 index 6dc839ac5b..0000000000 --- a/.github/workflows/request-review.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: request-review - -on: - pull_request_target: - types: [opened, synchronize, reopened, ready_for_review, converted_to_draft] - -permissions: - contents: read - pull-requests: write - -concurrency: - group: request-review-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - request-review: - name: request-review - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - # SECURITY: the write-capable job executes policy from the trusted default - # branch and reads pull-request filenames only as API data. - - name: Check out trusted review policy - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - ref: ${{ github.event.repository.default_branch }} - persist-credentials: false - - name: Request reviewers - env: - GITHUB_TOKEN: ${{ github.token }} - run: node .github/review-ownership/request-review.mjs 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/AGENTS.md b/AGENTS.md index a479e76625..b5b8d0295c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ DeepSeek Harness is an all-plugin Cordis agent harness. Read [docs/architecture. ## Pre-stable APIs and released Session data -Public APIs are pre-stable; update every consumer. Released Session JSONL follows [adjacent migration](.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md): body reads may add a version-named successor but never move, overwrite, or delete committed generations; predecessors imply neither fallback nor downgrade support. SQLite domains use monotonic `SCHEMA_VERSION`. +Public APIs are pre-stable; update every consumer. [Session version/status](docs/session-format-status.md) defines the authorities. [Adjacent migration](.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md) may add a version-named successor but never move, overwrite, or delete committed generations; predecessors imply neither fallback nor downgrade support. SQLite uses monotonic `SCHEMA_VERSION`. **Application launch.** Only `dsh` profiles launch supported Node apps; package bins, demos, and public SDK argv escapes are forbidden ([rule](docs/architecture.md#application-launch)). @@ -45,7 +45,7 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// interaction/ approval/interaction capabilities, permission, commands, ask-user boot/ shared profile/application boot glue sdk/ JSON-RPC protocol + TypeScript client/server - experimental/ private prototypes excluded from official releases + experimental/ pre-stable prototypes; private by default with explicit public exceptions support/ dev/test infrastructure util/ zero-dependency utilities python/ Python SDK/runtime (see python/README.md) diff --git a/apps/cli/package.json b/apps/cli/package.json index 57084f2ac5..430451a4d4 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh", "description": "dsh CLI: profile boot, plugin management, and the browser UI alias", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/apps/cli/tests/profiles/headless/tests/expected/mcp-pagination/stderr-cause.txt b/apps/cli/tests/profiles/headless/tests/expected/mcp-pagination/stderr-cause.txt new file mode 100644 index 0000000000..19a6f28bec --- /dev/null +++ b/apps/cli/tests/profiles/headless/tests/expected/mcp-pagination/stderr-cause.txt @@ -0,0 +1 @@ +Error: mcp-client(pagination-cycle): server repeated a tools/list continuation cursor — invalid tool list diff --git a/apps/cli/tests/profiles/headless/tests/mcp-pagination.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/mcp-pagination.expected.e2e.ts new file mode 100644 index 0000000000..e9e08bb302 --- /dev/null +++ b/apps/cli/tests/profiles/headless/tests/mcp-pagination.expected.e2e.ts @@ -0,0 +1,30 @@ +/** Startup diagnostics through the shipped headless profile and real MCP stdio transport. */ + +import { fileURLToPath } from 'node:url' +import { expect, it } from 'vitest' +import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' + +const fixtureRoot = new URL('../../../../../../packages/mcp/mcp-client/tests/fixtures/', import.meta.url) +const configPath = fileURLToPath(new URL('repeated-cursor.patch.yml', fixtureRoot)) +const expectedPath = fileURLToPath(new URL('./expected/mcp-pagination/stderr-cause.txt', import.meta.url)) + +it('reports a repeated MCP discovery cursor and exits before starting a turn', async () => { + const { stdout, stderr } = await runLoaderSmoke({ + label: 'MCP discovery pagination cycle', + tempDirPrefix: 'dsh-mcp-pagination-', + binScript: fileURLToPath(new URL('../../../../src/bin.ts', import.meta.url)), + libBinScript: fileURLToPath(new URL('../../../../lib/bin.js', import.meta.url)), + configPath, + binArgs: ['--profile', 'headless', '--patch', configPath, 'unreachable task'], + tsconfigPath: fileURLToPath(new URL('../../../../../../tsconfig.json', import.meta.url)), + expectedExitCode: 1, + env: { + DSH_MCP_PAGINATION_FIXTURE: fileURLToPath(new URL('repeated-cursor-server.ts', fixtureRoot)), + DSH_TELEMETRY_DISABLED: '1', + }, + }) + expect(stdout).toBe('') + expect(stderr).toContain('initial connection or tool synchronization failed') + const cause = stderr.split('\n').find(line => line.startsWith('Error: mcp-client(pagination-cycle):')) + await expect(`${cause}\n`).toMatchFileSnapshot(expectedPath) +}, LOADER_SMOKE_TEST_TIMEOUT_MS) diff --git a/apps/desktop-host/package.json b/apps/desktop-host/package.json index fc44413c3d..68996a4894 100644 --- a/apps/desktop-host/package.json +++ b/apps/desktop-host/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-desktop-host", "description": "Private upstream-Node host process for the Electron desktop application", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "private": true, "license": "MIT", "type": "module", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d4a8146a1c..b428a0550c 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-desktop", "description": "Electron desktop shell for an isolated pnpm-installed dsh runtime", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "private": true, "license": "MIT", "type": "module", diff --git a/apps/web/package.json b/apps/web/package.json index 6c6dbc31aa..439036b0db 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-frontend", "description": "Web application entry: vite build over the @deepseek-ai/dsh-client-web shell library; dist/ served by apps/cli's dsh web", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/apps/web/tests/README.i18n.yaml b/apps/web/tests/README.i18n.yaml index ae7b0d6022..4a06bf19ed 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: 2361ed9840859bb7e8c5472afb6bf79a77ae1daf -README.zh.md: ec20030fda5d491b097e8fbcca4d5d651fb6af86 +README.md: 883d93d746afa6bac0b67f2c2d6bc6c03b31553a +README.zh.md: 7a232329b890a669240272aada3a5bafff9efd99 diff --git a/apps/web/tests/README.md b/apps/web/tests/README.md index 2361ed9840..883d93d746 100644 --- a/apps/web/tests/README.md +++ b/apps/web/tests/README.md @@ -4,6 +4,10 @@ English | [中文](README.zh.md) These tests boot the real web composition in-process and drive it with a real Chromium over real HTTP. The lane's mechanics — modes, fixtures, goldens, and 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, because they read Host services directly: `ctx.connection`, the Host `SessionStore`, and `ctx.sessionProjectionCache`. Driving a browser at runtime does not make a file part of the Client program — the two faces merge Cordis `Context` under the same keys with different services, so one program cannot see both. Moving these files into the Client aggregate makes every Host-service access fail to compile. diff --git a/apps/web/tests/README.zh.md b/apps/web/tests/README.zh.md index ec20030fda..7a232329b8 100644 --- a/apps/web/tests/README.zh.md +++ b/apps/web/tests/README.zh.md @@ -4,6 +4,10 @@ 这些测试在进程内启动真实的 web 组合,并用真实 Chromium 通过真实 HTTP 驱动它。该 lane 的运行机制——模式、fixture(测试前置数据)、golden,以及与 `dsh web` 之间刻意保留的组合差异——记录在 [`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 中,因为它们直接读取 Host 服务:`ctx.connection`、Host 侧 `SessionStore` 与 `ctx.sessionProjectionCache`。运行时驱动浏览器并不使一个文件成为 Client 程序的一部分——两个 face 在相同的键上以不同服务合并 Cordis `Context`,因此单个程序无法同时看见两者。把这些文件挪进 Client aggregate 会让每一处 Host 服务访问都无法编译。 diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index b153e29dff..8f1bf2033a 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -353,7 +353,7 @@ describe('web e2e: agent-preset selection', () => { expect(snapshot).toContain('Minimal mode') expect(snapshot).toContain('button "1 subagent"') expect(snapshot.indexOf('button "1 subagent"')).toBeLessThan(snapshot.indexOf('Minimal mode')) - expect(snapshot.indexOf('Minimal mode')).toBeLessThan(snapshot.indexOf('button "Session log"')) + expect(snapshot.indexOf('Minimal mode')).toBeLessThan(snapshot.indexOf('button "More actions"')) // Static chrome, not a control: the header can only report a composition // the host would refuse to change. expect(snapshot).not.toContain('button "Minimal mode"') diff --git a/apps/web/tests/agent-team-panel.e2e.ts b/apps/web/tests/agent-team-panel.e2e.ts index 9ebea7cdd0..b3192c3655 100644 --- a/apps/web/tests/agent-team-panel.e2e.ts +++ b/apps/web/tests/agent-team-panel.e2e.ts @@ -1,4 +1,4 @@ -// Keyless assembled-browser coverage for the private Agent Teams Web profiles +// Keyless assembled-browser coverage for the opt-in Agent Teams Web profiles // over the real Host Typert Remote flow. import { fileURLToPath } from 'node:url' import { join } from 'node:path' 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/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index a8189f65de..41af1afa6b 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -18,6 +18,10 @@ const PAGE_LINES = 64 const SHOT_DIR = fileURLToPath(new URL('../../../.artifacts/screenshots/0908-document-preview', import.meta.url)) const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.' const MODE = webSnapshotMode() +const TINY_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'base64', +) /** Successful render evidence stays outside the committed snapshot inventory. */ async function successShot(page: Page, name: string): Promise { @@ -76,7 +80,7 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () } }) - it('opens Markdown, isolated HTML, and a rendered PDF from the Session workspace', async () => { + it('opens text, isolated HTML, intrinsic images, and rendered PDF from the Session workspace', async () => { onTestFailed(async () => { await mkdir(SHOT_DIR, { recursive: true }) await saveFailureShot(page, `screenshots/0908-document-preview/smoke-${process.pid}`) @@ -118,6 +122,13 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () writeFile(join(cwd, 'local.js'), 'document.getElementById("local-result").textContent="LOCAL_JS_OK";'), writeFile(join(cwd, 'local.css'), '#local-result { color: rgb(12, 34, 56); }'), writeFile(outsideScript, 'document.getElementById("outside-result").textContent="OUTSIDE_JS_OK";'), + writeFile(join(cwd, 'tiny.png'), TINY_PNG), + writeFile(join(cwd, 'large.svg'), [ + '', + '', + '', + '', + ].join('')), writeFile(join(cwd, 'smoke.pdf'), pdfFixture()), ]) @@ -130,9 +141,10 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () expect(await column.locator('[data-dockkit-tab]').count()).toBe(1) const defaultTitle = await filesTab.locator('[data-dockkit-tab-title]').innerText() const initialFilesClose = await filesTab.locator('[data-dockkit-tab-close]').count() - expect(initialFilesClose).toBe(0) + expect(initialFilesClose).toBe(1) await filesTab.click({ button: 'right' }) - expect(await page.locator('[data-dockkit-tab-menu]:visible').count()).toBe(0) + expect(await page.locator('[data-dockkit-tab-menu]:visible').count()).toBe(1) + await page.keyboard.press('Escape') await addTab.waitFor({ state: 'visible' }) const initialAdd = await addTab.count() expect(initialAdd).toBe(1) @@ -143,9 +155,10 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () const guideClose = await guideTab.locator('[data-dockkit-tab-close]').count() const filesCloseWithGuide = await filesTab.locator('[data-dockkit-tab-close]').count() expect(guideClose).toBe(1) - expect(filesCloseWithGuide).toBe(0) + expect(filesCloseWithGuide).toBe(1) await expect.poll(() => addTab.count()).toBe(0) const addWithGuide = await addTab.count() + await guideTab.hover() await guideTab.locator('[data-dockkit-tab-close]').click() await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' }) await expect.poll(() => column.locator('[data-sidebar-right-guide]').count()).toBe(0) @@ -153,7 +166,7 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () await addTab.waitFor({ state: 'visible' }) const restoredFilesClose = await filesTab.locator('[data-dockkit-tab-close]').count() const restoredAdd = await addTab.count() - expect(restoredFilesClose).toBe(0) + expect(restoredFilesClose).toBe(1) expect(restoredAdd).toBe(1) const preview = column.locator('[data-document-preview]') const openFile = async (name: string): Promise => { @@ -290,6 +303,50 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () `- Same tab: ${String(await pdfTab.getAttribute('data-dockkit-tab') === pdfTabId)}`, ].join('\n')) + await openFile('tiny.png') + await expect.poll(() => viewer.innerText()).toBe('Image') + const tinyImage = preview.getByRole('img', { name: 'Image preview: tiny.png', exact: true }) + await tinyImage.waitFor({ state: 'visible', timeout: 15_000 }) + expect(await tinyImage.evaluate(node => ({ + width: (node as HTMLImageElement).naturalWidth, + height: (node as HTMLImageElement).naturalHeight, + draggable: (node as HTMLImageElement).draggable, + }))).toEqual({ width: 1, height: 1, draggable: false }) + const centering = await tinyImage.evaluate((node) => { + const image = node.getBoundingClientRect() + const scroller = node.closest('[data-textpreview-body]')?.getBoundingClientRect() + if (scroller === undefined) throw new Error('image document scroller is unavailable') + return { + horizontal: Math.abs((image.left + image.width / 2) - (scroller.left + scroller.width / 2)), + vertical: Math.abs((image.top + image.height / 2) - (scroller.top + scroller.height / 2)), + } + }) + expect(centering.horizontal).toBeLessThan(10) + expect(centering.vertical).toBeLessThan(10) + + await openFile('large.svg') + await expect.poll(() => viewer.innerText()).toBe('Image') + const largeImage = preview.getByRole('img', { name: 'Image preview: large.svg', exact: true }) + await largeImage.waitFor({ state: 'visible', timeout: 15_000 }) + expect(await largeImage.evaluate(node => ({ + naturalWidth: (node as HTMLImageElement).naturalWidth, + naturalHeight: (node as HTMLImageElement).naturalHeight, + width: getComputedStyle(node).width, + height: getComputedStyle(node).height, + }))).toEqual({ naturalWidth: 1200, naturalHeight: 1600, width: '1200px', height: '1600px' }) + expect(await body.evaluate(node => ({ + horizontal: node.scrollWidth > node.clientWidth, + vertical: node.scrollHeight > node.clientHeight, + }))).toEqual({ horizontal: true, vertical: true }) + const scrolled = await body.evaluate((node) => { + node.scrollLeft = node.scrollWidth + node.scrollTop = node.scrollHeight + return { left: node.scrollLeft, top: node.scrollTop } + }) + expect(scrolled.left).toBeGreaterThan(0) + expect(scrolled.top).toBeGreaterThan(0) + expect(await page.locator('html').getAttribute('data-image-preview-escape')).toBeNull() + const releaseRead = Promise.withResolvers() let waitingForRead = false const readPage = scaffold.ctx.workspaceFiles.read.bind(scaffold.ctx.workspaceFiles) diff --git a/apps/web/tests/expected/agent-preset-selection/header.expected.md b/apps/web/tests/expected/agent-preset-selection/header.expected.md index 2a59c43dde..e81602eb6a 100644 --- a/apps/web/tests/expected/agent-preset-selection/header.expected.md +++ b/apps/web/tests/expected/agent-preset-selection/header.expected.md @@ -6,8 +6,7 @@ - img - img - text: Minimal mode -- button "Session log": - - text: Session log +- button "More actions": - img -- button "Open the sidebar": +- button "Open right sidebar": - img 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 1bc1752380..a0600f02d1 100644 --- a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md +++ b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md @@ -1,10 +1,9 @@ - banner: - navigation "Session hierarchy": - button "Clickable links gallery" [disabled] - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] @@ -170,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/file-upload-round/draft.expected.md b/apps/web/tests/expected/file-upload-round/draft.expected.md index aa5ef30e82..59e438946d 100644 --- a/apps/web/tests/expected/file-upload-round/draft.expected.md +++ b/apps/web/tests/expected/file-upload-round/draft.expected.md @@ -2,6 +2,9 @@ - selection order: poem.txt > reference-1.png > reference-2.png > reference-3.png > reference-4.png > reference-5.png > reference-6.png > reference-7.png > reference-8.png > reference-9.png > reference-10.png - one attachment group: true +- file icon dimensions: 28 × 28 +- file icon color: rgb(207, 211, 214) +- file icon uses a solid fill: true - all cards share one row: true - every card is 64px high: true - the file card is wider than an image: true diff --git a/apps/web/tests/expected/file-upload-round/history.expected.md b/apps/web/tests/expected/file-upload-round/history.expected.md index 1427ef8004..4db5550793 100644 --- a/apps/web/tests/expected/file-upload-round/history.expected.md +++ b/apps/web/tests/expected/file-upload-round/history.expected.md @@ -2,6 +2,9 @@ - source order: poem.txt > reference-1.png - one attachment group: true +- file icon dimensions: 28 × 28 +- file icon color: rgb(207, 211, 214) +- file icon uses a solid fill: true - file and image share one row: true - both cards are 64px high: true - the image is a 64px tile: true diff --git a/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md b/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md index 92887b13f2..2ae1fa24fa 100644 --- a/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md +++ b/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md @@ -14,10 +14,9 @@ - button "Review deepseek-harness/deepseek-harness#314" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/apps/web/tests/expected/github-ready-review/conversation.expected.md b/apps/web/tests/expected/github-ready-review/conversation.expected.md index aa35d67e04..b8b2c1f554 100644 --- a/apps/web/tests/expected/github-ready-review/conversation.expected.md +++ b/apps/web/tests/expected/github-ready-review/conversation.expected.md @@ -14,10 +14,9 @@ - button "Review deepseek-harness/deepseek-harness#314" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/apps/web/tests/expected/goal-command-presentation/ui.expected.md b/apps/web/tests/expected/goal-command-presentation/ui.expected.md index d5fddccca9..a9d28a21e7 100644 --- a/apps/web/tests/expected/goal-command-presentation/ui.expected.md +++ b/apps/web/tests/expected/goal-command-presentation/ui.expected.md @@ -3,10 +3,9 @@ - button "workspace" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md b/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md index 8cccd6e2a7..e09d2dc04f 100644 --- a/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md +++ b/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md @@ -1,10 +1,9 @@ - banner: - navigation "Session hierarchy": - button "CJK strong emphasis" [disabled] - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/apps/web/tests/expected/markdown-images/ui.expected.md b/apps/web/tests/expected/markdown-images/ui.expected.md index c010943b60..8d8f01047a 100644 --- a/apps/web/tests/expected/markdown-images/ui.expected.md +++ b/apps/web/tests/expected/markdown-images/ui.expected.md @@ -1,10 +1,9 @@ - banner: - navigation "Session hierarchy": - button "Markdown image policy" [disabled] - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md b/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md index e042455176..6a9dfa0298 100644 --- a/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md +++ b/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md @@ -1,10 +1,9 @@ - banner: - navigation "Session hierarchy": - button "Inline code links" [disabled] - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/apps/web/tests/expected/math-rendering/ui.expected.md b/apps/web/tests/expected/math-rendering/ui.expected.md index 20e51e6c63..0f4c7db9f8 100644 --- a/apps/web/tests/expected/math-rendering/ui.expected.md +++ b/apps/web/tests/expected/math-rendering/ui.expected.md @@ -1,10 +1,9 @@ - banner: - navigation "Session hierarchy": - button "Math rendering" [disabled] - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/apps/web/tests/expected/reference-composer/order.expected.md b/apps/web/tests/expected/reference-composer/order.expected.md index 7ea2707435..5ca8cc92cc 100644 --- a/apps/web/tests/expected/reference-composer/order.expected.md +++ b/apps/web/tests/expected/reference-composer/order.expected.md @@ -1,10 +1,9 @@ - banner: - navigation "Session hierarchy": - button "Reference order target" [disabled] - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/apps/web/tests/expected/settings-chrome/dialog.expected.md b/apps/web/tests/expected/settings-chrome/dialog.expected.md index 2f620a88d4..f80893bfb2 100644 --- a/apps/web/tests/expected/settings-chrome/dialog.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog.expected.md @@ -41,8 +41,8 @@ - button "减小字号": - img - text: px 对话显示 控制已完成轮次的过程内容 - - button "Compact": - - text: Compact + - button "紧凑": + - text: 紧凑 - img - text: 繁忙时的发送行为 智能体运行时 Enter 键和发送按钮的行为;Cmd/Ctrl+Enter 使用另一行为 - button "排队发送": 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/expected/skill-user-invoke/ui-expanded.expected.md b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md index f14b04d9a9..e2821f6fbb 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md @@ -3,10 +3,9 @@ - button "/user-invoke-demo and confirm the fixtur" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/apps/web/tests/expected/skill-user-invoke/ui.expected.md b/apps/web/tests/expected/skill-user-invoke/ui.expected.md index e0901950d2..7e65f42144 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui.expected.md @@ -3,10 +3,9 @@ - button "/user-invoke-demo and confirm the fixtur" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/apps/web/tests/expected/stats-paged-history/ui.expected.md b/apps/web/tests/expected/stats-paged-history/ui.expected.md index 6a3f9ecbb6..d76b371ba1 100644 --- a/apps/web/tests/expected/stats-paged-history/ui.expected.md +++ b/apps/web/tests/expected/stats-paged-history/ui.expected.md @@ -1,10 +1,9 @@ - banner: - navigation "Session hierarchy": - button "{{workspace}}" [disabled] - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/apps/web/tests/expected/steer-all/mid-steer.expected.md b/apps/web/tests/expected/steer-all/mid-steer.expected.md index 11e83471a3..5fcde028a1 100644 --- a/apps/web/tests/expected/steer-all/mid-steer.expected.md +++ b/apps/web/tests/expected/steer-all/mid-steer.expected.md @@ -3,10 +3,9 @@ - button "Use the ask_user_question tool to" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/apps/web/tests/expected/steer-all/settled-expanded.expected.md b/apps/web/tests/expected/steer-all/settled-expanded.expected.md index b1644d07d5..c4dbd84fb6 100644 --- a/apps/web/tests/expected/steer-all/settled-expanded.expected.md +++ b/apps/web/tests/expected/steer-all/settled-expanded.expected.md @@ -3,10 +3,9 @@ - button "Use the ask_user_question tool to" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/apps/web/tests/expected/steer-all/settled.expected.md b/apps/web/tests/expected/steer-all/settled.expected.md index acbaf1b148..6dbe3e41c2 100644 --- a/apps/web/tests/expected/steer-all/settled.expected.md +++ b/apps/web/tests/expected/steer-all/settled.expected.md @@ -3,10 +3,9 @@ - button "Use the ask_user_question tool to" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] 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/file-upload-round.e2e.ts b/apps/web/tests/file-upload-round.e2e.ts index d39babbf16..5df27af9d9 100644 --- a/apps/web/tests/file-upload-round.e2e.ts +++ b/apps/web/tests/file-upload-round.e2e.ts @@ -38,6 +38,9 @@ const IMAGE_NAMES = Array.from({ length: 10 }, (_unused, index) => `reference-${ /** Browser-measured relations for the mixed composer attachment rail. */ interface DraftRailGeometry { + readonly fileIconBox: string + readonly fileIconColor: string + readonly fileIconUsesSolidFill: boolean readonly order: readonly string[] readonly oneGroup: boolean readonly oneRow: boolean @@ -54,6 +57,9 @@ function renderDraftRailGeometry(geometry: DraftRailGeometry): string { '', `- selection order: ${geometry.order.join(' > ')}`, `- one attachment group: ${String(geometry.oneGroup)}`, + `- file icon dimensions: ${geometry.fileIconBox}`, + `- file icon color: ${geometry.fileIconColor}`, + `- file icon uses a solid fill: ${String(geometry.fileIconUsesSolidFill)}`, `- all cards share one row: ${String(geometry.oneRow)}`, `- every card is 64px high: ${String(geometry.equalHeight)}`, `- the file card is wider than an image: ${String(geometry.fileWider)}`, @@ -64,6 +70,9 @@ function renderDraftRailGeometry(geometry: DraftRailGeometry): string { /** Browser-measured relations for one durable mixed-attachment message. */ interface HistoryAttachmentGeometry { + readonly fileIconBox: string + readonly fileIconColor: string + readonly fileIconUsesSolidFill: boolean readonly order: readonly string[] readonly oneGroup: boolean readonly oneRow: boolean @@ -81,6 +90,9 @@ function renderHistoryAttachmentGeometry(geometry: HistoryAttachmentGeometry): s '', `- source order: ${geometry.order.join(' > ')}`, `- one attachment group: ${String(geometry.oneGroup)}`, + `- file icon dimensions: ${geometry.fileIconBox}`, + `- file icon color: ${geometry.fileIconColor}`, + `- file icon uses a solid fill: ${String(geometry.fileIconUsesSolidFill)}`, `- file and image share one row: ${String(geometry.oneRow)}`, `- both cards are 64px high: ${String(geometry.equalHeight)}`, `- the image is a 64px tile: ${String(geometry.imageIsTile)}`, @@ -145,12 +157,18 @@ describe('web e2e: generic file upload through the real assembly', () => { const rail = page.getByRole('group', { name: 'Pending attachments' }) await expect.poll(() => rail.locator(':scope > *').count(), { timeout: 10_000 }) .toBe(IMAGE_NAMES.length + 1) + await rail.locator('svg[viewBox="0 0 28 28"]').waitFor({ timeout: 15_000 }) const geometry = await rail.evaluate((element): DraftRailGeometry => { const cards = [...element.children] as HTMLElement[] const boxes = cards.map(card => card.getBoundingClientRect()) const imageWidth = boxes[cards.findIndex(card => card.querySelector('img') !== null)]?.width ?? 0 - const fileWidth = boxes[cards.findIndex(card => card.querySelector('[title="poem.txt"]') !== null)]?.width ?? 0 + const fileCard = cards.find(card => card.querySelector('[title="poem.txt"]') !== null)! + const fileWidth = fileCard.getBoundingClientRect().width + const fileIcon = fileCard.querySelector('svg[viewBox="0 0 28 28"]')! return { + fileIconBox: `${fileIcon.getBoundingClientRect().width} × ${fileIcon.getBoundingClientRect().height}`, + fileIconColor: getComputedStyle(fileIcon).color, + fileIconUsesSolidFill: fileIcon.querySelector('path')?.getAttribute('fill') === 'currentColor', order: cards.map(card => card.querySelector('img')?.getAttribute('alt') ?? card.querySelector('[title]')?.title ?? ''), oneGroup: document.querySelectorAll('[role="group"][aria-label="Pending attachments"]').length === 1, @@ -247,7 +265,11 @@ describe('web e2e: generic file upload through the real assembly', () => { const fileIndex = cards.findIndex(card => card.getAttribute('title') === 'poem.txt') const imageBox = boxes[imageIndex] const fileBox = boxes[fileIndex] + const fileIcon = cards[fileIndex]!.querySelector('svg')! return { + fileIconBox: `${fileIcon.getBoundingClientRect().width} × ${fileIcon.getBoundingClientRect().height}`, + fileIconColor: getComputedStyle(fileIcon).color, + fileIconUsesSolidFill: fileIcon.querySelector('path')?.getAttribute('fill') === 'currentColor', order: cards.map(card => card.getAttribute('title') ?? card.querySelector('img')?.getAttribute('alt') ?? ''), oneGroup: document.querySelectorAll('[data-message-attachments]').length === 1, oneRow: boxes.every(box => Math.abs(box.top - (boxes[0]?.top ?? box.top)) < 0.5), 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/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index 297257c678..a73c9dd65b 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -274,12 +274,12 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { it.skipIf(MODE === 'record')('downloads through the Session Header and /export with one dialog', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-export')) await ensureSeedOpen(page) - const exportButton = page.getByRole('button', { name: 'Session log' }) + const exportButton = page.getByRole('button', { name: 'More actions' }) expect(await exportButton.isDisabled()).toBe(false) const header = exportButton.locator('xpath=ancestor::header[1]') // The right Sidebar's expand button holds the header's corner; the export // control sits immediately to its left. - const sidebarButton = page.getByRole('button', { name: 'Open the sidebar' }) + const sidebarButton = page.getByRole('button', { name: 'Open right sidebar' }) const [buttonBox, sidebarBox, headerBox] = await Promise.all([ exportButton.boundingBox(), sidebarButton.boundingBox(), header.boundingBox(), ]) @@ -293,6 +293,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { && new URL(response.url()).pathname === '/api/session.export', { timeout: 30_000 }) const downloadPromise = page.waitForEvent('download', { timeout: 30_000 }) await exportButton.click() + await page.getByRole('menuitem', { name: 'Download session log' }).click() const response = await responsePromise expect(response.status()).toBe(200) const download = await downloadPromise @@ -402,14 +403,13 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { // the right column either — the expanded terminal card is read in place. await page.locator('[data-sample="bash"] ~ div [data-terminal] [class*="_copyButton_"]').first().click() await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe('true') - // Read summaries are file links: one click opens the file as a text-preview - // tab in the right Sidebar, which expands to show it beside the guide tab. + // Opening a file into the empty column creates only its preview tab. const fileLink = page.locator('[data-variant="read"] button').first() await fileLink.waitFor({ timeout: 10_000 }) await fileLink.click() await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe(null) const column = page.locator('[data-rightbar-col]') - await expect.poll(() => column.locator('[data-dockkit-tab-title]').count(), { timeout: 5_000 }).toBe(2) + await expect.poll(() => column.locator('[data-dockkit-tab-title]').allTextContents(), { timeout: 5_000 }).toEqual(['nav-a.md']) // Put the column back so later cases start from the default frame. await column.locator('[data-sidebar-right-toggle]').click() await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe('true') diff --git a/apps/web/tests/present-svg.e2e.ts b/apps/web/tests/present-svg.e2e.ts new file mode 100644 index 0000000000..9fc93f131b --- /dev/null +++ b/apps/web/tests/present-svg.e2e.ts @@ -0,0 +1,123 @@ +/** 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, + extraOverlayPath: fileURLToPath(new URL('./present-svg.overlay.yml', import.meta.url)), + ...(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-file]').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) + await expect.poll(() => page.getByRole('button', { name: `${FILE} 的更多文件操作`, exact: true }).isDisabled()).toBe(true) + // 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/present-svg.overlay.yml b/apps/web/tests/present-svg.overlay.yml new file mode 100644 index 0000000000..a575a00026 --- /dev/null +++ b/apps/web/tests/present-svg.overlay.yml @@ -0,0 +1,4 @@ +# This transcript includes the no-desktop state on every test platform. +- id: session-controller + config: + nativeOpen: false diff --git a/apps/web/tests/present.e2e.ts b/apps/web/tests/present.e2e.ts index 52aee329fa..d1b4bc713c 100644 --- a/apps/web/tests/present.e2e.ts +++ b/apps/web/tests/present.e2e.ts @@ -35,7 +35,7 @@ describe.skipIf(process.platform === 'win32' || release().toLowerCase().includes const events: SessionEvent[] = [] let nativeRoot: string | undefined let openLog: string - const opened = async (): Promise> => (await readFile(openLog, 'utf8')).split('\n').filter(Boolean).map(line => JSON.parse(line) as { path: string; content: string }) + const opened = async (): Promise> => (await readFile(openLog, 'utf8')).split('\n').filter(Boolean).map(line => JSON.parse(line) as { path: string; content: string | null; action: 'open' | 'reveal' }) const downloads: string[] = [] beforeAll(async () => { @@ -46,11 +46,14 @@ describe.skipIf(process.platform === 'win32' || release().toLowerCase().includes const command = process.platform === 'darwin' ? 'open' : 'xdg-open' await writeFile(join(nativeRoot, command), `#!${process.execPath} const fs = require('node:fs'); -fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path: process.argv[2], content: fs.readFileSync(process.argv[2], 'utf8') }) + '\\n'); +const path = process.argv[2] === '-R' ? process.argv[3] : process.argv[2]; +const action = process.argv[2] === '-R' || fs.statSync(path).isDirectory() ? 'reveal' : 'open'; +fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, content: action === 'open' ? fs.readFileSync(path, 'utf8') : null }) + '\\n'); `, { mode: 0o700 }) vi.stubEnv('PATH', `${nativeRoot}${delimiter}${process.env.PATH ?? ''}`) await mkdir(DIR, { recursive: true }) scaffold = await launchWebScaffold({ + extraOverlayPath: fileURLToPath(new URL('./present.overlay.yml', import.meta.url)), agentPresets: { roots: [], default: 'ptc' }, compareReplaySession: true, ...(MODE === 'record' ? {} : { replayFixture: FIXTURE }), }) @@ -118,15 +121,26 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path: process.arg } const row = page.locator('[data-presented-files-row]') await row.waitFor() - expect(await row.getByRole('button').count()).toBe(2) + expect(await row.getByRole('button', { name: /More file actions/ }).count()).toBe(2) + expect(await row.getByText('report.txt', { exact: true }).innerText()).toBe('report.txt') + const beforeReveal = (await opened()).length + await row.getByRole('button', { name: 'More file actions for report.txt', exact: true }).click() + const revealResponse = page.waitForResponse(response => response.url().includes('action=reveal') && response.request().method() === 'POST') + await page.getByRole('menuitem', { name: process.platform === 'darwin' ? /Show in Finder/ : /Open containing folder/ }).click() + expect((await revealResponse).status()).toBe(204) + expect(await row.getByRole('button', { name: 'Open report.txt in sidebar', exact: true }) + .evaluate(button => button === document.activeElement)).toBe(true) + await expect.poll(opened).toHaveLength(beforeReveal + 1) + expect((await opened()).at(-1)).toEqual({ action: 'reveal', content: null, path: await realpath(process.platform === 'darwin' ? join(cwd, 'report.txt') : cwd) }) for (const [name, bytes] of [['report.txt', 'EDITED_REPORT\n'], ['说明.txt', 'EDITED_NOTE\n']] as const) { const count = (await opened()).length const response = page.waitForResponse(response => response.url().includes('/api/present.open?') && response.request().method() === 'POST') - await row.getByRole('button', { name: `Open ${name} in default app`, exact: true }).click() + await row.getByRole('button', { name: `More file actions for ${name}`, exact: true }).click() + await page.getByRole('menuitem', { name: 'Open in default app', exact: true }).click() expect((await response).status()).toBe(204) await page.waitForFunction(() => document.querySelector('[data-presented-files-row] button:disabled') === null) expect(await opened()).toHaveLength(count + 1) - expect((await opened()).at(-1)).toEqual({ path: await realpath(join(cwd, name)), content: bytes }) + expect((await opened()).at(-1)).toEqual({ action: 'open', path: await realpath(join(cwd, name)), content: bytes }) } } const count = (await opened()).length @@ -135,7 +149,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path: process.arg await page.waitForFunction(() => document.querySelector('[data-presented-files-row] button:disabled') === null) expect((await openedResponse).status()).toBe(204) expect(await opened()).toHaveLength(count + 1) - expect((await opened()).at(-1)).toEqual({ path: await realpath(join(cwd, 'report.txt')), content: 'EDITED_REPORT\n' }) + expect((await opened()).at(-1)).toEqual({ action: 'open', path: await realpath(join(cwd, 'report.txt')), content: 'EDITED_REPORT\n' }) expect(downloads).toEqual([]) const response = await page.request.get(new URL(`/api/session.export?sessionId=${sessionId}`, scaffold.authenticatedUrl).href) expect(response.status()).toBe(200) @@ -175,7 +189,8 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path: process.arg const beforeDelete = (await opened()).length await unlink(join(cwd, 'report.txt')) const missing = page.waitForResponse(response => response.url().includes('/api/present.open?')) - await page.locator('[data-presented-files-row]').getByRole('button', { name: 'Open report.txt in default app', exact: true }).click() + await page.locator('[data-presented-files-row]').getByRole('button', { name: 'More file actions for report.txt', exact: true }).click() + await page.getByRole('menuitem', { name: 'Open in default app', exact: true }).click() expect((await missing).status()).toBe(404) await page.getByText('Could not open. Click to retry.', { exact: true }).waitFor() expect(await opened()).toHaveLength(beforeDelete) diff --git a/apps/web/tests/present.overlay.yml b/apps/web/tests/present.overlay.yml new file mode 100644 index 0000000000..a39a66dad9 --- /dev/null +++ b/apps/web/tests/present.overlay.yml @@ -0,0 +1,4 @@ +# Native commands are owned fixtures; desktop availability must be identical in headless CI. +- id: session-controller + config: + nativeOpen: true diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index 90dc202cf0..0819e0e8ce 100644 --- a/apps/web/tests/preview-boot.e2e.ts +++ b/apps/web/tests/preview-boot.e2e.ts @@ -34,7 +34,10 @@ import { IMAGE_FILE_NAME, PREVIEW_FIXTURE_MANIFEST_FILE, PREVIEW_FIXTURE_MANIFEST_VERSION, type PreviewFixtureManifest, } from '@deepseek-ai/dsh-experimental-webworker-runtime' -import { buildVfsExampleFiles } from '../../../packages/experimental/webworker-runtime/tests/vfs-example-fixture.ts' +import { + VFS_EXAMPLE_SESSION_IDS, + buildVfsExampleFiles, +} from '../../../packages/experimental/webworker-runtime/tests/vfs-example-fixture.ts' import { captureStableAria, compareOrRefreshGolden, webSnapshotMode } from './scaffold.ts' import { newEnglishPage, REPO_ROOT, saveFailureShot } from './support.ts' @@ -317,7 +320,7 @@ async function bootPreview(origin: string, browser: Browser): Promise { await page.locator('[data-composer-input][data-placeholder="Describe what you want to build, / commands, @ files or sessions"]') .waitFor({ timeout: 30_000 }) - const exercised = await page.evaluate(async () => { + const exercised = await page.evaluate(async ({ seededSessionId, seededSessionTitle }) => { type Result = { result: { ok: true; value: T } | { ok: false; error: { code: string; message: string } } } interface PreviewTransport { fetch(input: string, init: RequestInit): Promise @@ -352,6 +355,14 @@ async function bootPreview(origin: string, browser: Browser): Promise { if (!body.result.ok) throw new Error(`${endpoint} failed: ${body.result.error.message}`) return body.result.value } + // Keep the fixture title stable for later UI assertions; increasing seqs + // prove that the cold Session acquired its write lease and appended. + const firstRename = await remote<{ title: string; seq: number }>('session/rename', { + request: { sessionId: seededSessionId, title: seededSessionTitle }, + }) + const secondRename = await remote<{ title: string; seq: number }>('session/rename', { + request: { sessionId: seededSessionId, title: seededSessionTitle }, + }) const skills = await remote<{ skills: Array<{ name: string }> }>( 'skills/list', { request: { sessionId } }, ) @@ -384,10 +395,17 @@ async function bootPreview(origin: string, browser: Browser): Promise { await remote('credentials/unset', { ref: 'PREVIEW_TEST_SECRET' }) await new Promise((resolve) => { setTimeout(resolve, 250) }) return { + renamedTitle: secondRename.title, + renameAdvanced: secondRename.seq > firstRename.seq, skillCount: skills.skills.length, credentialConfigured: credentials.PREVIEW_TEST_SECRET?.configured, } + }, { + seededSessionId: VFS_EXAMPLE_SESSION_IDS.main, + seededSessionTitle: SHOWCASE_TITLE, }) + expect(exercised.renamedTitle).toBe(SHOWCASE_TITLE) + expect(exercised.renameAdvanced).toBe(true) expect(exercised.skillCount).toBeGreaterThan(0) expect(exercised.credentialConfigured).toBe(true) @@ -398,8 +416,8 @@ async function bootPreview(origin: string, browser: Browser): Promise { await page.getByText(SHOWCASE_TAIL, { exact: true }).waitFor({ timeout: 30_000 }) expect(await page.getByText(SHOWCASE_OLDEST, { exact: true }).count()).toBe(0) - await page.getByText('PREVIEW.md', { exact: true }).waitFor() - await page.getByText('src/preview.ts', { exact: true }).waitFor() + await page.getByRole('button', { name: 'PREVIEW.md', exact: true }).waitFor() + await page.getByRole('button', { name: 'src/preview.ts', exact: true }).waitFor() await page.getByText('Update to-do list', { exact: true }).waitFor() await page.getByText('Error: ENOENT: no such file, open missing.txt', { exact: true }).waitFor() 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/scaffold.ts b/apps/web/tests/scaffold.ts index 4bc734aa4b..2d9b118753 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -296,8 +296,8 @@ export interface LaunchOptions { */ extraOverlayPath?: string /** - * Additional source-checkout package manifests whose dependency closures - * supply private profile layers named by {@link extraOverlayPath}. + * Additional package manifests whose dependency closures supply experimental + * profile layers named by {@link extraOverlayPath}. */ extraInstallAnchors?: string[] /** diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 09110c5360..a9d713b93b 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -420,7 +420,7 @@ describe('web e2e: seeded history renders through cold resume', () => { await fileLink.click() await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe(null) const column = page.locator('[data-rightbar-col]') - await expect.poll(() => column.locator('[data-dockkit-tab-title]').count(), { timeout: 5_000 }).toBe(2) + await expect.poll(() => column.locator('[data-dockkit-tab-title]').allTextContents(), { timeout: 5_000 }).toEqual(['a.txt']) // Path label survives from the recorded args (a.txt). await expect.poll(() => page.getByText('a.txt', { exact: false }).count(), { timeout: 5_000 }).toBeGreaterThan(0) const path = column.locator('[data-textpreview-path]') @@ -433,7 +433,7 @@ describe('web e2e: seeded history renders through cold resume', () => { // Put the column back so the later goldens see the default frame. await column.locator('[data-sidebar-right-toggle]').click() await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe('true') - await page.getByRole('button', { name: 'Open the sidebar', exact: true }).waitFor({ state: 'visible' }) + await page.getByRole('button', { name: 'Open right sidebar', exact: true }).waitFor({ state: 'visible' }) await page.getByRole('navigation', { name: 'Turn navigation', exact: true }).waitFor({ state: 'visible' }) }) diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 8ef2af7048..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) @@ -458,9 +489,9 @@ describe('web e2e: settings modal and General preferences', () => { const dialog = page.getByRole('dialog', { name: '设置' }) await dialog.waitFor({ timeout: 10_000 }) await dialog.getByText('对话显示', { exact: true }).waitFor({ timeout: 10_000 }) - await dialog.getByRole('button', { name: 'Compact', exact: true }).click() - await page.getByRole('menuitem', { name: 'Normal', exact: true }).click() - await dialog.getByRole('button', { name: 'Normal', exact: true }).waitFor({ timeout: 10_000 }) + await dialog.getByRole('button', { name: '紧凑', exact: true }).click() + await page.getByRole('menuitem', { name: '标准', exact: true }).click() + await dialog.getByRole('button', { name: '标准', exact: true }).waitFor({ timeout: 10_000 }) await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 }) .toMatch(/ui-chat:\n\s+transcriptView: normal/) await page.keyboard.press('Escape') @@ -471,11 +502,11 @@ describe('web e2e: settings modal and General preferences', () => { acknowledgeReloadConnectionLoss(tripwire, warningStart) await page.getByRole('button', { name: '设置', exact: true }).click() const reloaded = page.getByRole('dialog', { name: '设置' }) - await reloaded.getByRole('button', { name: 'Normal', exact: true }).waitFor({ timeout: 10_000 }) + await reloaded.getByRole('button', { name: '标准', exact: true }).waitFor({ timeout: 10_000 }) - await reloaded.getByRole('button', { name: 'Normal', exact: true }).click() - await page.getByRole('menuitem', { name: 'Compact', exact: true }).click() - await reloaded.getByRole('button', { name: 'Compact', exact: true }).waitFor({ timeout: 10_000 }) + await reloaded.getByRole('button', { name: '标准', exact: true }).click() + await page.getByRole('menuitem', { name: '紧凑', exact: true }).click() + await reloaded.getByRole('button', { name: '紧凑', exact: true }).waitFor({ timeout: 10_000 }) await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 }) .toMatch(/ui-chat:\n\s+transcriptView: compact/) await page.keyboard.press('Escape') @@ -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/sidebar-right.e2e.ts b/apps/web/tests/sidebar-right.e2e.ts index 257e0c9f6e..e0a64084f5 100644 --- a/apps/web/tests/sidebar-right.e2e.ts +++ b/apps/web/tests/sidebar-right.e2e.ts @@ -20,7 +20,7 @@ import { mkdirSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { fileURLToPath } from 'node:url' -import type { Browser, Locator, Page } from 'playwright' +import type { Browser, ConsoleMessage, Locator, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' @@ -265,9 +265,9 @@ describe('web e2e: shipped right Sidebar', () => { // real because the preview reads it through the workspace endpoint. // // It goes in the SESSION's cwd, not the scaffold's: the endpoint resolves - // relative paths against `sandboxPolicy.resolve({session}).workspaceRoot`, - // which is the session header's cwd. Writing anywhere else makes the read - // fail with workspace-file/not-found, which is the endpoint being right. + // relative paths against the header-derived workspace root. Writing + // anywhere else makes the read fail with workspace-file/not-found, which + // is the endpoint being right. writeFileSync(join(agent.session.header.cwd ?? scaffold.workspaceCwd, SAMPLE_NAME), SAMPLE_TEXT, 'utf8') agent.session.append('tool/call', { turn: 1, @@ -360,14 +360,13 @@ describe('web e2e: shipped right Sidebar', () => { expect(panelWidth).toBeGreaterThan(0) expect(await width(conversation)).toBe(centerBefore - panelWidth) await expect.poll(async () => await expand.count()).toBe(0) - // The corner keeps its footprint, so the utilities' right edge stays where - // it was relative to the conversation's own right edge. - expect(await page.locator('[data-sidebar-right-expand-placeholder]').count()).toBe(1) + // The corner seat collapses with its button, so the utilities' right edge + // moves out toward the conversation's own. const utilitiesAfter = await utilities.boundingBox() const conversationAfter = await conversation.boundingBox() if (utilitiesAfter === null || conversationAfter === null) throw new Error('header is not rendered') const gapAfter = (conversationAfter.x + conversationAfter.width) - (utilitiesAfter.x + utilitiesAfter.width) - expect(Math.round(gapAfter)).toBe(Math.round(gapBefore)) + expect(gapAfter).toBeLessThan(gapBefore) // The panel is in the column, not over it, and carries the seeded tab — // whose body arrives through the Files type's keyed registration, not from @@ -391,23 +390,24 @@ describe('web e2e: shipped right Sidebar', () => { expect(await centreY(selector), selector).toBe(textLine) } - // Files is permanent. A manual guide is closable and suppresses another - // add control in its pane until it is closed. + // A manual guide is closable beside Files and suppresses another add + // control in its pane until it is closed. const addTab = column.locator('[data-dockkit-add-tab]') const filesTab = column.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }) await expect.poll(async () => await tabTitles(column)).toEqual(['Files']) await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' }) - expect(await filesTab.locator('[data-dockkit-tab-close]').count()).toBe(0) + expect(await filesTab.locator('[data-dockkit-tab-close]').count()).toBe(1) await expect.poll(async () => await addTab.count()).toBe(1) expect(await centreY('[data-dockkit-add-tab]')).toBe(textLine) await addTab.click() await expect.poll(async () => await tabTitles(column)).toEqual(['Files', 'Start']) await expect.poll(async () => await column.locator('[data-sidebar-right-guide]').count()).toBe(1) await expect.poll(async () => await addTab.count()).toBe(0) - expect(await filesTab.locator('[data-dockkit-tab-close]').count()).toBe(0) + expect(await filesTab.locator('[data-dockkit-tab-close]').count()).toBe(1) const guideTab = column.locator('[data-dockkit-tab]').filter({ hasText: 'Start' }) expect(await guideTab.locator('[data-dockkit-tab-close]').count()).toBe(1) // Back to the seeded shape the cases below start from. + await guideTab.hover() await guideTab.locator('[data-dockkit-tab-close]').click() await expect.poll(async () => await tabTitles(column)).toEqual(['Files']) await expect.poll(async () => await addTab.count()).toBe(1) @@ -592,21 +592,97 @@ describe('web e2e: shipped right Sidebar', () => { expect(tripwire.warnings).toEqual([]) }) + it('survives grip drags past both clamps on a squeezed viewport', async () => { + // Regression: an overshoot drag swept the panel through widths where a + // width-blocked split control hid itself, which changed the very strip + // measurement that had blocked it — a layout-effect feedback loop that + // crashed the pane (React error #185) and unmounted the rightbar slot + // entry while the column still believed it was expanded, so neither the + // panel nor the header's expand button remained. The crash surfaces only + // as a console error, which the scaffold tripwire does not watch, so this + // case collects console errors itself. + const viewport = page.viewportSize() + if (viewport === null) throw new Error('expected a fixed viewport') + const column = await resetSidebar(page) + const frame = page.locator('[class*="frame"]').first() + const panel = column.locator('[data-sidebar-right-panel]') + const consoleErrors: string[] = [] + const collect = (message: ConsoleMessage): void => { + if (message.type() === 'error') consoleErrors.push(message.text().slice(0, 600)) + } + page.on('console', collect) + try { + await page.setViewportSize({ width: 1000, height: viewport.height }) + await ensureExpanded(page, column) + await width(column) + const grip = frame.locator('[data-side="rightbar"]') + // The frame reads the new viewport through a throttled ResizeObserver, + // a couple of frames after the resize; until then the grip sits at the + // old frame's coordinates. Press only a grip aligned with the panel's + // left edge (the handle is 8px wide, centred on the seam). + await expect.poll(async () => { + const gripBox = await grip.boundingBox() + const panelBox = await panel.boundingBox() + if (gripBox === null || panelBox === null) return Number.NaN + return Math.abs(gripBox.x + 4 - panelBox.x) + }).toBeLessThanOrEqual(1) + // Narrow with overshoot: drag the grip far right past the clamp floor. + const from = await centre(grip) + await page.mouse.move(from.x, from.y) + await page.mouse.down() + await page.mouse.move(980, from.y, { steps: 30 }) + await page.mouse.up() + // The panel holds its floor, still open, with its grip still rendered. + await expect.poll(async () => await width(panel)).toBeLessThanOrEqual(302) + expect(await width(panel)).toBeGreaterThanOrEqual(300) + expect(await column.locator('[data-sidebar-right-open]').count()).toBe(1) + expect(await grip.count()).toBe(1) + // Widen with overshoot to the far left: clamped by the frame's range. + const back = await centre(grip) + await page.mouse.move(back.x, back.y) + await page.mouse.down() + await page.mouse.move(20, back.y, { steps: 30 }) + await page.mouse.up() + const widened = await width(panel) + expect(widened).toBeGreaterThan(302) + expect(widened).toBeLessThan(1000) + expect(await column.locator('[data-sidebar-right-open]').count()).toBe(1) + expect(await grip.count()).toBe(1) + expect(consoleErrors).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } finally { + page.off('console', collect) + await page.setViewportSize(viewport) + await ensureExpanded(page, column) + await setPanelWidth(page, Math.round(viewport.width * 0.45)) + } + }, 60_000) + it('CONTROL: the host endpoint answers when called directly, bypassing the wire', async () => { const files = (scaffold.ctx as unknown as { get(name: string): { - read(agent: unknown, path: string, range: object, signal: AbortSignal): Promise<{ text: string; eof: boolean }> + read( + scope: { sessionId: string; workspaceRoot: string }, + path: string, + range: object, + signal: AbortSignal, + ): Promise<{ text: string; eof: boolean }> } | undefined }).get('workspaceFiles') if (files === undefined) throw new Error('host endpoint is not provided') const agent = scaffold.ctx.agents.list()[0] if (agent === undefined) throw new Error('no Agent to read for') + const scope = { + sessionId: agent.session.id, + workspaceRoot: agent.session.header.cwd ?? scaffold.workspaceCwd, + } // Raced against a timer so a hang reports a verdict instead of stalling // the suite: this case exists to tell host logic apart from the wire. // A page is the file's lines joined by `\n`, without the final terminator. const verdict = await Promise.race([ - files.read(agent, SAMPLE_NAME, {}, new AbortController().signal) + files.read(scope, SAMPLE_NAME, {}, new AbortController().signal) .then(value => ({ kind: 'settled' as const, text: value.text, eof: value.eof })) .catch((error: unknown) => ({ kind: 'threw' as const, text: String(error), eof: false })), new Promise<{ kind: 'hung'; text: string; eof: boolean }>((resolve) => { @@ -686,7 +762,7 @@ describe('web e2e: shipped right Sidebar', () => { await expect.poll(async () => await panes.count()).toBe(2) const splitFiles = panes.nth(1).locator('[data-dockkit-tab]').filter({ hasText: 'Files' }) - expect(await splitFiles.locator('[data-dockkit-tab-close]').count()).toBe(0) + expect(await splitFiles.locator('[data-dockkit-tab-close]').count()).toBe(1) await dragTo(page, splitFiles, await pointIn(panes.first(), 0.5, 0.5)) await expect.poll(async () => await tabTitles(panes.nth(1))).toEqual([SAMPLE_NAME]) @@ -850,33 +926,58 @@ describe('web e2e: shipped right Sidebar', () => { expect(tripwire.warnings).toEqual([]) }, 90_000) - it('keeps the last tab unclosable and drops a pane emptied by a drag', async () => { + it('drops a pane whose last tab closes, and follows the last-tab rule on the surface', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-settle')) const column = await resetSidebar(page) const panes = column.locator('[data-dockkit-pane]') - expect(await column.locator('[data-dockkit-tab-close]').count()).toBe(0) + expect(await column.locator('[data-dockkit-tab-close]').count()).toBe(1) await page.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click() await expect.poll(async () => await tabTitles(panes.first())).toEqual(['Files', SAMPLE_NAME]) await panes.first().locator('[data-dockkit-split-button]').click() await expect.poll(async () => await panes.count()).toBe(2) - await dragTo(page, panes.first().locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME }), - await pointIn(panes.nth(1), 0.5, 0.5)) - await expect.poll(async () => await tabTitles(panes.nth(1))).toEqual(['Files', SAMPLE_NAME]) - const splitFiles = panes.nth(1).locator('[data-dockkit-tab]').filter({ hasText: 'Files' }) - expect(await splitFiles.locator('[data-dockkit-tab-close]').count()).toBe(0) - await dragTo(page, splitFiles, await pointIn(panes.first(), 0.5, 0.5)) - await expect.poll(async () => await tabTitles(panes.nth(1))).toEqual([SAMPLE_NAME]) - // The remaining ordinary document is also unclosable while alone. - expect(await panes.nth(1).locator('[data-dockkit-tab-close]').count()).toBe(0) - await dragTo(page, panes.nth(1).locator('[data-dockkit-tab]'), await pointIn(panes.first(), 0.5, 0.5)) + // Closing a pane's last tab drops the pane: there is no separate + // "close pane" gesture, and none is needed. + await panes.nth(1).locator('[data-dockkit-tab]').first().hover() + await panes.nth(1).locator('[data-dockkit-tab-close]').first().click() await expect.poll(async () => await panes.count()).toBe(1) - const documentTab = panes.first().locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME }) - await expect.poll(async () => await documentTab.locator('[data-dockkit-tab-close]').count()).toBe(1) - await documentTab.locator('[data-dockkit-tab-close]').click() - await expect.poll(async () => await documentTab.count()).toBe(0) - expect(await panes.count()).toBe(1) + await expect.poll(async () => await tabTitles(column)).toEqual(['Files', SAMPLE_NAME]) + + // Leave a guide as the sole docked tab. + const files = column.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }) + await files.hover() + await files.locator('[data-dockkit-tab-close]').click() + await column.locator('[data-dockkit-add-tab]').click() + await expect.poll(async () => await tabTitles(column)).toEqual([SAMPLE_NAME, 'Start']) + const sample = column.locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME }) + await sample.hover() + await sample.locator('[data-dockkit-tab-close]').click() + await expect.poll(async () => await tabTitles(column)).toEqual(['Start']) + + // The guide standing as the docked surface's only tab draws no close + // control, sits quiet (no capsule, no hover fill), and a secondary press + // opens no menu: an empty menu never shows. expect(await column.locator('[data-dockkit-tab-close]').count()).toBe(0) + expect(await column.locator('[data-dockkit-tab-quiet]').count()).toBe(1) + await column.locator('[data-dockkit-tab]').first().click({ button: 'right' }) + expect(await page.locator('[data-dockkit-tab-menu]').isVisible()).toBe(false) + expect(await page.getByRole('menu').count()).toBe(0) + + // Any other tab standing alone closes together with the column. Open the + // sample file, close the guide (an ordinary close with two tabs), then + // close the file: the column collapses in the same gesture, and the + // settle rule reseeds the current default, so reopening shows Files. + await page.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click() + await expect.poll(async () => await tabTitles(column)).toEqual(['Start', SAMPLE_NAME]) + await column.locator('[data-dockkit-tab]').first().hover() + await column.locator('[data-dockkit-tab-close]').first().click() + await expect.poll(async () => await tabTitles(column)).toEqual([SAMPLE_NAME]) + await column.locator('[data-dockkit-tab]').first().hover() + await column.locator('[data-dockkit-tab-close]').first().click() + await expect.poll(async () => await column.locator('[data-sidebar-right-open]').count()).toBe(0) + await expandOf(page).click() + await expect.poll(async () => await tabTitles(column)).toEqual(['Files']) + expect(await column.locator('[data-files-state="tree"]').count()).toBe(1) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) @@ -959,8 +1060,8 @@ describe('web e2e: shipped right Sidebar', () => { // as a layout defect that is not there. expect(await width(column)).toBeGreaterThan(300) await expect.poll(async () => await tabTitles(column)).toEqual(['文件', '开始']) - await expect.poll(async () => await guide.locator('p').first().innerText()) - .toBe('侧栏用来放你想一直看着的东西。') + await expect.poll(async () => await guide.locator('[data-sidebar-right-guide-entry="files"]').innerText()) + .toBe('工作区文件') await shot(zhPage, '05-guide-copy-zh') expect(zhTripwire.pageErrors).toEqual([]) diff --git a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md index 1ca602fbd7..da207c2c52 100644 --- a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md +++ b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md @@ -3,10 +3,9 @@ - button "Stream one TypeScript fence for" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] 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 b14490baa1..c13ad6f35d 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: 04c6e9bc8c66202b7800f4931b2226f420c428fb +README.md: 489939499af8d98922df2cbbdd6be793bf6e3796 +README.zh.md: e51fac2d0df005de9d609cb634778f30b537ae41 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 04c6e9bc8c..e51fac2d0d 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/AGENTS.md b/docs/AGENTS.md index bb248a9042..7c345ecac8 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -35,7 +35,7 @@ Placement: bugs → postmortems; rationale → Agent Notes; procedures → cookb ## Writing rules -- **Document current state, not change history.** Avoid "previously/now/no longer", PRs, commits, and stack positions in durable prose; name the live mechanism. Put change stories in commits, PRs, Agent Notes, or postmortems; the latter two may cite merged PRs and issues as evidence. +- **Document current state, not change history.** Name live mechanisms, not PRs, commits, stack positions, or "previously/now/no longer". Keep history in commits, PRs, Agent Notes, or postmortems. General Session-format prose links [version/status authority](session-format-status.md); retain numbers for version-specific contracts, examples, or evidence. - **Every non-trivial change includes at least one Agent Note in the same PR.** Update the owning note or add one; only mechanical/local edits are exempt ([scope](../.agents/notes/README.md#when-to-write-one)). - **One physical line per paragraph** (`verify-md-wrap`): use editor soft-wrap. Code blocks, tables, and list structure keep their formatting; code comments stay under the linter's column limit. - **Fenced `ts` blocks must compile** (`doc-typecheck`); a pasted type declaration and its original JSDoc use ` ```ts type-equiv `, while a body-stripped public class declaration uses ` ```ts public-api `; register either in the manifest so neither can drift ([mechanics](development.md#documenting-types-verbatim-ts-type-equiv)). diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index d9d1bd54f3..d591a9fa36 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: fed98f006471f86f02c43bcb0c6ea4dfe7850da8 -architecture.zh.md: b1ec7ddaf0278a14cd7c18d6cc272fc59c5c3781 +architecture.md: 688341582044e72e8548c8e6b1535450793ddce4 +architecture.zh.md: 6817986015a2aa90d6fcb094da3e972ccfe08483 diff --git a/docs/architecture.md b/docs/architecture.md index fed98f0064..6883415820 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -128,7 +128,7 @@ A **seam** is a swappable capability with three roles: a **Service Definition** Seams are why one provider swap changes the whole product. Filesystem and subprocess providers share one execution world, so pointing them at a remote sandbox moves Bash, PTY, and LSP with them, with no provider forks. [Subagent providers](subsystems/subagent.md) vary just as widely behind one interface, from a fresh child agent to a delegated turn in another product. -[Experimental Agent Teams](subsystems/agent-team.md) is a private opt-in coordination seam on `ctx.agentTeams`, with a durable roster, task board, and mailbox layered over continuable subagents. +[Experimental Agent Teams](subsystems/agent-team.md) is a published opt-in coordination seam on `ctx.agentTeams`, with a durable roster, task board, and mailbox layered over continuable subagents. ## Where new behavior goes diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index b1ec7ddaf0..6817986015 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -132,7 +132,7 @@ Session 消费方只了解当前逻辑格式。仅 header 的 `stat` 与 `list` seam 正是替换一个提供方就能改变整个产品的原因。文件系统与进程提供方共享同一个执行世界,因此把它们指向远程沙箱,也就把 Bash、PTY 和 LSP 一并搬了过去,无需提供方专用 fork。[subagent 提供方](subsystems/subagent.zh.md)在同一个接口之后同样千差万别,从新建一个子 agent,到把一个轮次委派给另一个产品。 -[实验性 Agent Teams](subsystems/agent-team.zh.md) 是 `ctx.agentTeams` 上的私有显式启用协作 seam,在可继续 subagent 之上提供持久 roster、任务板和 mailbox。 +[实验性 Agent Teams](subsystems/agent-team.zh.md) 是 `ctx.agentTeams` 上公开发布、显式启用的协作 seam,在可继续 subagent 之上提供持久 roster、任务板和 mailbox。 ## 新行为的归属位置 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 bfc5221538..901cce8be7 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: 50626a7d85aa54f6d2e3cdb6140b3af8081d1461 -config-catalog.zh.md: d2ae35b91a4a83da882e8ec5af82a505aa5d5bd1 +config-catalog.md: 9adf44b995601196b602e9219e53c52208039051 +config-catalog.zh.md: e2f6c6babc18bfa689f6830c81231012b4d9de18 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 50626a7d85..9adf44b995 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -213,7 +213,7 @@ export interface Config { } ``` -Source: [`packages/api/session-controller/src/index.ts:70`](../packages/api/session-controller/src/index.ts) +Source: [`packages/api/session-controller/src/index.ts:71`](../packages/api/session-controller/src/index.ts) @@ -233,7 +233,7 @@ Source: [`packages/api/settings-controller/src/index.ts:36`](../packages/api/set ## `@deepseek-ai/dsh-api-workspace-files` -Requires: `fs` · `sandboxPolicy` · `typert` +Requires: `fs` · `sandboxPolicy` · `sessions` · `typert` ```ts config-catalog /** Deployment caps on one page or one listing. */ @@ -255,7 +255,7 @@ export interface Config { } ``` -Source: [`packages/api/workspace-files/src/index.ts:50`](../packages/api/workspace-files/src/index.ts) +Source: [`packages/api/workspace-files/src/index.ts:69`](../packages/api/workspace-files/src/index.ts) @@ -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) @@ -3459,7 +3459,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-commands` ([`packages/client/ui-commands/src/index.ts`](../packages/client/ui-commands/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) - `@deepseek-ai/dsh-client-ui-cordis` ([`packages/extensions/ui-cordis/src/index.ts`](../packages/extensions/ui-cordis/src/index.ts)) -- `@deepseek-ai/dsh-client-ui-deliverables` — requires `systemPrompt` · `connection` · `sessionQuery` · `sessionController` ([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-deliverables` — requires `systemPrompt` · `connection` · `sessionQuery` · `sessionController` · `workspaceFiles` · `fs` · `sandboxPolicy` ([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) - `@deepseek-ai/dsh-client-ui-directory-picker-browse` ([`packages/client/ui-directory-picker-browse/src/index.ts`](../packages/client/ui-directory-picker-browse/src/index.ts)) - `@deepseek-ai/dsh-client-ui-directory-picker-native` ([`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts)) - `@deepseek-ai/dsh-client-ui-goal` ([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index d2ae35b91a..e2f6c6babc 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -235,7 +235,7 @@ export interface Config { ## `@deepseek-ai/dsh-api-workspace-files` -Requires: `fs` · `sandboxPolicy` · `typert` +Requires: `fs` · `sandboxPolicy` · `sessions` · `typert` ```ts config-catalog /** Deployment caps on one page or one listing. */ @@ -257,7 +257,7 @@ export interface Config { } ``` -来源:[`packages/api/workspace-files/src/index.ts:50`](../packages/api/workspace-files/src/index.ts) +来源:[`packages/api/workspace-files/src/index.ts:69`](../packages/api/workspace-files/src/index.ts) @@ -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) @@ -3461,7 +3461,7 @@ export interface Config { - `@deepseek-ai/dsh-client-ui-commands`([`packages/client/ui-commands/src/index.ts`](../packages/client/ui-commands/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation`([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) - `@deepseek-ai/dsh-client-ui-cordis`([`packages/extensions/ui-cordis/src/index.ts`](../packages/extensions/ui-cordis/src/index.ts)) -- `@deepseek-ai/dsh-client-ui-deliverables` — 需要 `systemPrompt` · `connection` · `sessionQuery` · `sessionController`([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-deliverables` — 需要 `systemPrompt` · `connection` · `sessionQuery` · `sessionController` · `workspaceFiles` · `fs` · `sandboxPolicy`([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) - `@deepseek-ai/dsh-client-ui-directory-picker-browse`([`packages/client/ui-directory-picker-browse/src/index.ts`](../packages/client/ui-directory-picker-browse/src/index.ts)) - `@deepseek-ai/dsh-client-ui-directory-picker-native`([`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts)) - `@deepseek-ai/dsh-client-ui-goal`([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/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..020554516a 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: 54af2806bf7193ce9d1f5cd6c75f92b809c4ed95 +adding-a-session-format-version.zh.md: 4f7135b00aba37758343b8c17f2b4aff3146eeab diff --git a/docs/cookbook/adding-a-session-format-version.md b/docs/cookbook/adding-a-session-format-version.md index c2d3b966c4..54af2806bf 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. Read the [version and release-status authority](../session-format-status.md) to identify the checkout writer and the latest released format. Let N denote that verified released format and N+1 the target; substitute numeric values for these placeholders in names and metadata. 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. +Use a shared `release/*` integration base for N+1. The base change adds the 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 same adjacent migration package. Do not allocate extra versions 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 remain frozen. Do not amend a released edge 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. +Follow the package checklist to create a library for N→N+1, not a mounted plugin. An identity body conversion is only an initial wiring scaffold. The [V2-to-V3 specification](../../packages/session/session-format-v2-to-v3/README.md#v2-to-v3-specification) is a fixed 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. +Declare `dsh.sessionFormatMigration` with numeric `from: N` and `to: N+1`, an export path, and the exported migration, source codec, target codec, target-header validator, and target restorer. Reuse the source codec exported by the preceding edge package and depend on that package; do not copy or redefine a released codec. Export the target codec and validators from the new package. Add the 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 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 seeded multi-hop restoration from each supported historical generation through N+1, not just direct N 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 using the target version’s canonical parent and child filenames. Never rename a predecessor to the target filename 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,9 +91,9 @@ 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: +Update the [owning Agent Note](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md) rather than adding a redundant decision record. Keep the [release record](../session-format-status.md#updating-the-record) unchanged until publication; after publication, update it with verified release evidence. 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: ```sh pnpm run verify-translation-pairing --write docs/cookbook/adding-a-session-format-version.md diff --git a/docs/cookbook/adding-a-session-format-version.zh.md b/docs/cookbook/adding-a-session-format-version.zh.md index 6bb93f5ff9..4f7135b00a 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 日志版本,同时不改写已发布数据。阅读[版本与发布状态真源](../session-format-status.zh.md),确定工作区写入器与最新已发布格式。令 N 表示经核实的已发布格式,N+1 表示目标版本;名称与元数据中的这些占位符须替换为数字。开始前,请准备可用的贡献者工作区,并阅读[包检查清单](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 将评审后的子分支合入发布分支,并在发布前验证组合结果。遵守发布分支的强制推送与删除保护;不要强制同步该分支。 +为 N+1 使用共享的 `release/*` 集成基线。基线变更添加写入器、codec、catalog 接线、恒等迁移与验证。从该基线创建各个独立子分支,并将其 PR(Pull Request)的目标设为发布分支,而非另一个独立子分支。每个子分支在同一个相邻迁移包内添加自身的结构变换、校验器、消费方和测试。不要只为表示评审顺序而分配额外版本。通过 PR 将评审后的子分支合入发布分支,并在发布前验证组合结果。遵守发布分支的强制推送与删除保护;不要强制同步该分支。 -已发布 codec 和迁移语义保持冻结。不要通过修改 V0→V1 或 V1→V2 来实现新的 V3 功能。在 V3 发布前,其唯一入边可以纳入这些协同变更;发布后,结构性变更需要下一条相邻迁移边。 +已发布 codec 和迁移语义保持冻结。不要通过修改已发布迁移边来实现新的结构性功能。只有 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)定义实际转换与保留规则。不要将其结构转换视为恒等迁移边。 +按照包检查清单为 N→N+1 创建库,而非挂载插件。恒等正文转换仅是最初的接线骨架。[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: N` 和 `to: N+1`、导出路径,以及导出的迁移、源 codec、目标 codec、目标 header 校验器和目标恢复器。复用前一条迁移边所属包导出的源 codec,并依赖该包;不要复制或重新定义已发布 codec。从新包导出目标 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,然后生成 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` 返回精确的目标截点。前一条改变事件数量的迁移边可能使该数量在构造时不可知。必要时从已校验的种子标记推导它,并测试从每个受支持历史代际到 N+1 的有种子多跳恢复,而非仅测试直接 N 输入。绝不以零替代未知截点。 -显式定义每条迁移边的事件准入与变换规则;[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 后,保留每份历史文件,并按目标版本的规范父子文件名生成后继文件。绝不将前代重命名为目标文件名,或仅修改其 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,9 +91,9 @@ 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),而非添加重复决策记录。审计相关活跃记录的取代关系;保留独立理由,并保持归档记录冻结。一起更新双语正文,通过仓库工具重新记录每个变更的配对,然后运行文档检查: +更新[所属 Agent Note](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md),而非添加重复决策记录。发布前保持[发布记录](../session-format-status.zh.md#updating-the-record)不变;发布后,使用已核实的发布证据更新它。审计相关活跃记录的取代关系;保留独立理由,并保持归档记录冻结。一起更新双语正文,通过仓库工具重新记录每个变更的配对,然后运行文档检查: ```sh pnpm run verify-translation-pairing --write docs/cookbook/adding-a-session-format-version.md diff --git a/docs/deepseek-llm-api-wire-extensions.i18n.yaml b/docs/deepseek-llm-api-wire-extensions.i18n.yaml index 27bcf349da..e0b7be4a17 100644 --- a/docs/deepseek-llm-api-wire-extensions.i18n.yaml +++ b/docs/deepseek-llm-api-wire-extensions.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/deepseek-llm-api-wire-extensions.md -deepseek-llm-api-wire-extensions.md: 5b1c4de68949d56f99cbfa70ba4af7ca0b71715d -deepseek-llm-api-wire-extensions.zh.md: a78baace34dcb3e24667295cf06e111ce355aa72 +deepseek-llm-api-wire-extensions.md: a6689c97670260a7b673fc73d455726e357fd022 +deepseek-llm-api-wire-extensions.zh.md: 9f54d78609186055d3ac5e3b9b4f4272c7304e83 diff --git a/docs/deepseek-llm-api-wire-extensions.md b/docs/deepseek-llm-api-wire-extensions.md index 5b1c4de689..a6689c9767 100644 --- a/docs/deepseek-llm-api-wire-extensions.md +++ b/docs/deepseek-llm-api-wire-extensions.md @@ -73,7 +73,7 @@ An enabled inventory with no qualifying entries sends `packages: []`; disabling ## `dsh_session_log` -[`@deepseek-ai/dsh-session-log-deepseek`](../packages/session/session-log-deepseek/README.md) contributes one contiguous suffix of the canonical Session log. The field is disabled by default. When enabled, it applies to a request with a live Session and at least one event; a direct request, a stale Session id, or an empty log omits the field. +[`@deepseek-ai/dsh-session-log-deepseek`](../packages/session/session-log-deepseek/README.md) contributes one contiguous suffix of the canonical Session log. The field is disabled by default. When enabled, it applies to a request with a live Session and at least one event; a direct request, a stale Session id, or an empty log omits the field. The examples below use logical Session format 2 only to illustrate the wire fields; they do not identify the [current writer format](session-format-status.md). ```json { @@ -119,7 +119,7 @@ The `session` member projects `Session.header`, not a complete runtime Session o | Member | Presence | Meaning | |---|---|---| -| `version` | required | Logical Session format version; currently `2` | +| `version` | required | Logical Session format version from `Session.header`; see [format status](session-format-status.md) | | `id` | required | Exact Session id | | `createdAt` | required | Non-negative safe-integer Unix epoch milliseconds | | `cwd` | optional | Absolute working directory recorded at Session creation | diff --git a/docs/deepseek-llm-api-wire-extensions.zh.md b/docs/deepseek-llm-api-wire-extensions.zh.md index a78baace34..9f54d78609 100644 --- a/docs/deepseek-llm-api-wire-extensions.zh.md +++ b/docs/deepseek-llm-api-wire-extensions.zh.md @@ -73,7 +73,7 @@ ## `dsh_session_log` -[`@deepseek-ai/dsh-session-log-deepseek`](../packages/session/session-log-deepseek/README.zh.md) 贡献权威会话日志的一段连续后缀。该字段默认禁用。启用后,它适用于携带存活会话且至少存在一个事件的请求;直接请求、陈旧会话 id 或空日志会省略该字段。 +[`@deepseek-ai/dsh-session-log-deepseek`](../packages/session/session-log-deepseek/README.zh.md) 贡献权威会话日志的一段连续后缀。该字段默认禁用。启用后,它适用于携带存活会话且至少存在一个事件的请求;直接请求、陈旧会话 id 或空日志会省略该字段。下方示例使用逻辑 Session 格式 2 仅为说明协议字段,并不标识[当前写入格式](session-format-status.zh.md)。 ```json { @@ -119,7 +119,7 @@ | 成员 | 出现条件 | 含义 | |---|---|---| -| `version` | 必需 | 逻辑 Session 格式版本;当前为 `2` | +| `version` | 必需 | 来自 `Session.header` 的逻辑 Session 格式版本;见[格式状态](session-format-status.zh.md) | | `id` | 必需 | 确切的会话 id | | `createdAt` | 必需 | 非负安全整数 Unix epoch 毫秒数 | | `cwd` | 可选 | 创建会话时记录的绝对工作目录 | diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index b1f10816f3..7f7e38eff1 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: 449b7d8f0fb55515e7f1f028e151ce1b26862e92 -event-producer-consumer.zh.md: 8120df6de6776bce8841f75311294583417532c2 +event-producer-consumer.md: 323e9f3eea8703c46f3be082db6ab67e9339084b +event-producer-consumer.zh.md: 1f34a6f3d53bfd6ddf6c19adb0a3c7cb7043c6ab diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 449b7d8f0f..323e9f3eea 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -22,11 +22,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:316`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:277`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:391`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:597`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:577`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:604`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:583`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:590`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:599`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:606`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:585`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:592`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:87`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | @@ -39,7 +39,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 8120df6de6..1f34a6f3d5 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -24,11 +24,11 @@ | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:316`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:277`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:391`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:597`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:577`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:604`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:583`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:590`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:599`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:606`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:585`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:592`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:87`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | @@ -41,7 +41,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/i18n/README.i18n.yaml b/docs/i18n/README.i18n.yaml index ed910c8e0d..b2f4630aba 100644 --- a/docs/i18n/README.i18n.yaml +++ b/docs/i18n/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 docs/i18n/README.md -README.md: 55ae07c18e09fde141ecf5344f715dfa25658325 -README.zh.md: 172fe063df3e5875d64fb02c09381ca9e76136f7 +README.md: 2ff8fc62f21d58a4d31b8aadd80c7a0c14556e6d +README.zh.md: 2e0c183f6f9374c08d698fbd7109eb4e59f87c10 diff --git a/docs/i18n/README.md b/docs/i18n/README.md index 55ae07c18e..2ff8fc62f2 100644 --- a/docs/i18n/README.md +++ b/docs/i18n/README.md @@ -51,7 +51,7 @@ Generated English references and graphs participate in pairing when a reviewed C - `docs/AGENTS.md`, `.agents/notes/**/AGENTS.md`, and their `CLAUDE.md` instruction symlinks — agent instructions, maintained in English only like the root `AGENTS.md`. - `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction. - [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior. -- [review-ownership/README.md](../../.github/review-ownership/README.md) and its [Agent Note](../../.agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.md) — repository-internal automation policy maintained in English only. +- [review-ownership/README.md](../../.github/review-ownership/README.md) — repository-internal approval policy maintained in English only. - `.agents/notes/archived/` — frozen historical triplets. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) validates their completeness and content seals; translation maintenance must never rewrite them. **Universal requirement**: every current or future document in scope must merge as a complete bilingual pair. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) contains only explicit exclusions; there is no per-file rollout list, date cutoff, or README-specific policy class. diff --git a/docs/i18n/README.zh.md b/docs/i18n/README.zh.md index 172fe063df..2e0c183f6f 100644 --- a/docs/i18n/README.zh.md +++ b/docs/i18n/README.zh.md @@ -53,7 +53,7 @@ - `docs/AGENTS.md`、`.agents/notes/**/AGENTS.md` 以及指向它们的 `CLAUDE.md` 指令符号链接:agent 指令,与根 `AGENTS.md` 一样只以英文维护。 - `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。 - [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。 -- [review-ownership/README.md](../../.github/review-ownership/README.md) 及其 [Agent Note](../../.agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.md):仓库内部自动化政策,只以英文维护。 +- [review-ownership/README.md](../../.github/review-ownership/README.md):仓库内部审批策略,只以英文维护。 - `.agents/notes/archived/`:冻结的历史三文件配对。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 校验其完整性和内容封存记录;翻译维护绝不能重写这些文件。 **统一要求**:当前及今后纳入范围的每篇文档,合并时都必须构成完整的双语配对。[scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 只包含显式排除项;不存在逐文件推进清单、日期分界或 README 专用政策类别。 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..389c95a40b 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: 46e91f23a7aad053791df190f769ad911f334b68 +persistence-catalog.zh.md: 087915b83d4dfd5c45a1583658d6ebe091904a3f diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index a9e1221a56..46e91f23a7 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -403,7 +403,7 @@ Source: [`packages/compaction/compaction/src/types.ts:34`](../packages/compactio #### `deliverables/presented` — log-only ```ts persistence-catalog -/** Declared workspace files from a successful final present result, including nested calls. */ +/** Declared filesystem files from a successful final present result, including nested calls. */ 'deliverables/presented': { turn: number; callId: ToolCallId; files: PresentedFile[] } ``` @@ -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..087915b83d 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -405,7 +405,7 @@ export type SessionEvent = { #### `deliverables/presented` — 仅日志 ```ts persistence-catalog -/** Declared workspace files from a successful final present result, including nested calls. */ +/** Declared filesystem files from a successful final present result, including nested calls. */ 'deliverables/presented': { turn: number; callId: ToolCallId; files: PresentedFile[] } ``` @@ -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/session-format-status.i18n.yaml b/docs/session-format-status.i18n.yaml new file mode 100644 index 0000000000..c12df73162 --- /dev/null +++ b/docs/session-format-status.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/session-format-status.md +session-format-status.md: 9076574b9b12b4f75615ff06939795a8f0051def +session-format-status.zh.md: 6c230479f8e41441f0c2e9848448a61d772a46b9 diff --git a/docs/session-format-status.md b/docs/session-format-status.md new file mode 100644 index 0000000000..9076574b9b --- /dev/null +++ b/docs/session-format-status.md @@ -0,0 +1,47 @@ +# Session format version and release status + +English | [中文](session-format-status.zh.md) + +## Summary + +Use this reference to distinguish the checkout’s Session writer version from the latest published Session format. The code constant owns the writer version; the release record below owns the latest released format and its publication evidence. Other documentation links here instead of restating which version is current, next, or unreleased. + +## Table of Contents + +- [Sources of truth](#sources-of-truth) +- [Release record](#release-record) +- [Updating the record](#updating-the-record) +- [Dev Note](#dev-note) + + +## Sources of truth + +- **Checkout writer:** `SESSION_FORMAT_VERSION` in [core Session types](../packages/core/session/src/types.ts) is the only hand-maintained current-writer number in code. The [catalog generator](../scripts/gen-session-format-catalog.ts) derives codec ordering and checks that adjacent migrations reach it. A package version, codec export name, fixture filename, or projection-cache version is not the writer authority. +- **Latest released format:** `latestReleasedVersion` in the following record identifies the published Session format. `evidenceTag` names a published product release whose tagged writer has that value; it need not be the first release carrying the format. The bilingual copy is checked against the same record, not maintained as a separate decision. +- **Release status:** compare the writer constant with the verified release record. Equality means the writer format has shipped. A greater writer version is a development target beyond the recorded release. When comparing an older checkout against a newer branch’s verified record, a lower writer version identifies an older writer format; the local consistency gate rejects that ordering within one checkout. No separate released boolean is maintained. Before declaring a greater version unreleased, verify that no published release has advanced the record. + +An alpha, beta, or release-candidate product publication establishes released Session-format obligations. GitHub’s prerelease flag does not make persisted user data disposable. A missing release record is not evidence of non-publication. The [versioning and authority decision](../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md) owns compatibility decisions; [released-format migration](../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md) owns immutable generations and adjacent conversion. + + +## Release record + +```yaml session-format-release +latestReleasedVersion: 3 +evidenceTag: dsh-v0.1.5-alpha.1 +``` + +Evidence: [published release](https://github.com/deepseek-harness/deepseek-harness/releases/tag/dsh-v0.1.5-alpha.1) and [its tagged writer source](https://github.com/deepseek-harness/deepseek-harness/blob/dsh-v0.1.5-alpha.1/packages/core/session/src/types.ts). + + +## Updating the record + +When a structural writer change is implemented, update the code constant and adjacent catalog together; do not advance this release record before publication. When a product release first publishes a higher Session format, confirm publication and its tagged writer, then advance this record and both evidence links in the same bilingual update. Later product releases carrying the same format do not require changing the record. Never lower it on the development trunk. + +The [documentation-standard test](../scripts/doc-standard.spec.ts) checks record structure, bilingual equality, evidence-link consistency, and that the documented release does not exceed the checkout writer. This keyless check does not query GitHub or prove that the record is up to date; publication verification remains part of the release update. + +Use “current format” and “next adjacent version” for general behavior. Keep explicit numbers for fixed migration inputs and outputs, wire schemas, historical evidence, and tests of those particular versions. The [format-version cookbook](cookbook/adding-a-session-format-version.md) uses N for the verified latest released format and N+1 for its successor. + + +## Dev Note + +None. diff --git a/docs/session-format-status.zh.md b/docs/session-format-status.zh.md new file mode 100644 index 0000000000..6c230479f8 --- /dev/null +++ b/docs/session-format-status.zh.md @@ -0,0 +1,47 @@ +# Session 格式版本与发布状态 + +[English](session-format-status.md) | 中文 + +## 概述 + +本参考区分工作区的 Session 写入器版本与最新已发布的 Session 格式。代码常量拥有写入器版本;下方发布记录拥有最新已发布格式及其发布证据。其他文档链接到这里,而不重复声明哪个版本是当前、下一个或尚未发布的版本。 + +## 目录 + +- [单一真源](#sources-of-truth) +- [发布记录](#release-record) +- [更新记录](#updating-the-record) +- [开发备注](#dev-note) + + +## 单一真源 + +- **工作区写入器:**[核心 Session 类型](../packages/core/session/src/types.ts)中的 `SESSION_FORMAT_VERSION` 是代码中唯一手工维护的当前写入器版本号。[目录生成器](../scripts/gen-session-format-catalog.ts)推导 codec 顺序,并检查相邻迁移是否到达该版本。包版本、codec 导出名称、fixture(测试前置数据)文件名或投影缓存版本都不是写入器版本的权威来源。 +- **最新已发布格式:**下方记录中的 `latestReleasedVersion` 标识已发布的 Session 格式。`evidenceTag` 指定一个已发布的产品版本,其标签对应的写入器具有该值;它不必是首次携带该格式的发布。双语副本按同一记录校验,不作为独立决策维护。 +- **发布状态:**比较写入器常量与已核实的发布记录。相等表示写入器格式已经发布。写入器版本更高表示它是超出记录中发布版本的开发目标。用较新分支中已核实的记录对比旧工作区时,较低的写入器版本表示较旧的写入器格式;本地一致性门禁会拒绝同一工作区内的这种大小关系。不另行维护 released 布尔值。在声明更高版本尚未发布前,必须核实是否已有产品发布推进了记录。 + +产品的 alpha、beta 或 release-candidate 发布都会确立已发布 Session 格式的义务。GitHub 的 prerelease 标记不会让持久化用户数据成为可丢弃数据。缺少发布记录不代表尚未发布。[版本与真源决策](../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md)拥有兼容性决策;[已发布格式迁移](../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md)拥有不可变代际与相邻转换规则。 + + +## 发布记录 + +```yaml session-format-release +latestReleasedVersion: 3 +evidenceTag: dsh-v0.1.5-alpha.1 +``` + +证据:[已发布产品版本](https://github.com/deepseek-harness/deepseek-harness/releases/tag/dsh-v0.1.5-alpha.1)及[对应标签的写入器源码](https://github.com/deepseek-harness/deepseek-harness/blob/dsh-v0.1.5-alpha.1/packages/core/session/src/types.ts)。 + + +## 更新记录 + +实现结构性写入器变更时,一起更新代码常量与相邻迁移目录;不要在产品发布前推进此发布记录。当产品首次发布更高的 Session 格式时,确认发布事实及对应标签的写入器,然后在同一次双语更新中推进本记录与两个证据链接。后续携带相同格式的产品发布无需改变此记录。开发主干上的记录绝不降低。 + +[文档标准测试](../scripts/doc-standard.spec.ts)检查记录结构、双语一致性、证据链接一致性,以及文档中的已发布版本不高于工作区写入器。这个无密钥检查不会查询 GitHub,也不能证明记录是最新的;核实发布事实仍属于发布更新的一部分。 + +一般行为使用“当前格式”和“下一条相邻版本”等表述。固定迁移的输入与输出、协议 schema、历史证据及针对特定版本的测试保留明确版本号。[格式版本实操手册](cookbook/adding-a-session-format-version.zh.md)用 N 表示已核实的最新发布格式,用 N+1 表示其后继版本。 + + +## 开发备注 + +无。 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..1abbf9957d 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: 7e18e9bbfec8c7aa81525da33ad3cffa4681ea73 +persistence.zh.md: 8250fb93b20dc33c2df705fd2c7092bc8dea155c diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md index 8ba4e74768..7e18e9bbfe 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 @@ -202,7 +202,7 @@ interface CreateSessionOptions { /** Initial replay or fork history supplied at construction. */ readonly seed?: readonly SessionEvent[] /** - * Exact fork-inherited prefix length when `meta.isSeeded` is true. In v2 the + * Exact fork-inherited prefix length when `meta.isSeeded` is true. The * constructor seed is exactly this inherited prefix; the constructor * appends the child-owned tagged marker at the cut. */ diff --git a/docs/subsystems/persistence.zh.md b/docs/subsystems/persistence.zh.md index 1821b07df7..8250fb93b2 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 与元数据 @@ -202,7 +202,7 @@ interface CreateSessionOptions { /** Initial replay or fork history supplied at construction. */ readonly seed?: readonly SessionEvent[] /** - * Exact fork-inherited prefix length when `meta.isSeeded` is true. In v2 the + * Exact fork-inherited prefix length when `meta.isSeeded` is true. The * constructor seed is exactly this inherited prefix; the constructor * appends the child-owned tagged marker at the cut. */ diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 26fee2e323..c7296b9ca5 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: 2b99ca8267d35978f245e6bdbf020226c976e363 +session.zh.md: e6cff2d239caf7cd99be13463b412479a6f6e79b diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index de0930ea7e..2b99ca8267 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -656,7 +656,7 @@ The optional `dsh-session/invariant` companion enforces the relations owned by c A fresh fork constructor requires its seed to equal the inherited prefix and appends `session/end-seed { inherited: true }` at the exact durable cut. A restore retains that tagged marker and appends an ordinary `session/end-seed {}` only when its complete stored seed does not already end in a marker. Both forms are log-only and produce no message; `Session`'s constructor is the only legitimate writer. -For fork lineage, locate the LAST marker whose payload carries `inherited: true`; v2 decoding requires it exactly when `SessionHeader.isSeeded` is true and derives `inheritedEventCount` from its seq. For lifecycle ownership, locate the last `session/end-seed` of either form. Reopening a seed that already ends in any marker does not append another ordinary marker. +For fork lineage, locate the LAST marker whose payload carries `inherited: true`; current-format decoding requires it exactly when `SessionHeader.isSeeded` is true and derives `inheritedEventCount` from its seq. For lifecycle ownership, locate the last `session/end-seed` of either form. Reopening a seed that already ends in any marker does not append another ordinary marker. It exists because seed history and live work are otherwise byte-identical, which defeats any plugin owning a standalone open/close bracket: an unmatched `compaction/start` reads the same whether the writer crashed mid-compaction or is compacting right now. An opening marker before `session/end-seed` came from the constructor seed and belongs to an ended lifecycle, whatever ended it (a crash, a succeeding process, or a fork out of a still-running parent), so its owner may treat it as dead. That covers only brackets *this* session inherited: a concurrently live session holding an open bracket over the same history has its own boundary elsewhere, so tolerating concurrent writers needs a liveness signal beyond the log. Core writes the boundary and reads nothing from it — a bracket's vocabulary stays with its owning plugin, which is why crash repair closes turn/step/tool boundaries and never `compaction/*`. @@ -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). @@ -680,7 +680,7 @@ The backends that consume this contract are on [persistence.md](persistence.md). `ModelCatalog` is the Host-generation model directory returned by `session/modelCatalog`: it carries the deployment default, routable provider ids, successful provider groups, and isolated provider failures. It is not derived from one Session and remains separate from Session projections. -`SessionOpenWorkspacePathRequest` carries an absolute or workspace-resolved `path`. `SessionOpenWorkspacePathValue` confirms that the Host accepted the native handoff. A Session-aware Client resolves relative paths against its current Session cwd when known; the controller hands the path to the opener unchanged and reports invalid requests, cancellation, and opener failures through the Session Remote error vocabulary. +`SessionOpenWorkspacePathRequest` carries an absolute or workspace-resolved `path`; optional `action: "reveal"` selects file-manager navigation instead of default-application opening. `SessionOpenWorkspacePathValue` confirms that the Host accepted the native handoff. A Session-aware Client resolves relative paths against its current Session cwd when known; the controller hands the path to the opener unchanged and reports invalid requests, cancellation, and opener failures through the Session Remote error vocabulary. @@ -754,6 +754,12 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise @@ -758,6 +758,12 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise +@Remote async read( workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceFileRange, signal: AbortSignal, ): Promise /** * Read one byte window of a regular file readable by the filesystem backend: raw * bytes, no text decoding and no binary rejection. - * @param agent - target Agent resolved from the Session identity on the wire. + * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. * @param path - absolute path or path relative to the workspace root; files outside it are allowed. * @param range - the byte window; omitted fields take the window defaults. * @param signal - caller cancellation. * @returns the window in base64, the file's version and size at the stat before it, and whether it reaches the last byte. */ -@Remote async readBytes(agent: Agent, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise +@Remote async readBytes( workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceByteRange, signal: AbortSignal, ): Promise /** * Read a complete regular file as bytes, subject to the configured full-file cap. - * @param agent - target Agent whose workspace resolves relative paths. + * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. * @param path - absolute or workspace-relative file path. * @param signal - caller cancellation. * @returns one complete base64 window with offset zero and eof true; oversized files fail with too-large. */ -@Remote async readAll(agent: Agent, path: string, signal: AbortSignal): Promise +@Remote async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise /** * Read a complete file relative to another file's directory, including outside the workspace. - * @param agent - Agent whose workspace resolves the base file's relative path. + * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. * @param path - base file, absolute or workspace-relative. * @param relativePath - relative filesystem path, not a URL or absolute path. * @param signal - caller cancellation. * @returns the complete related file using the ordinary file-size and access checks. */ -@Remote async readRelated(agent: Agent, path: string, relativePath: string, signal: AbortSignal): Promise +@Remote async readRelated( workspaceFileScope: WorkspaceFileScope, path: string, relativePath: string, signal: AbortSignal, ): Promise /** * Report one regular file's identity, version, and size without its content. - * @param agent - target Agent resolved from the Session identity on the wire. + * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. * @param path - absolute path or path relative to the workspace root; files outside it are allowed. * @param signal - caller cancellation. * @returns the file's absolute path, current version, and byte size. */ -@Remote async stat(agent: Agent, path: string, signal: AbortSignal): Promise +@Remote async stat(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise /** - * List the direct children of one directory inside the Agent's workspace. - * @param agent - target Agent resolved from the Session identity on the wire. + * List the direct children of one directory inside the Session's workspace. + * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. * @param path - workspace path, absolute or relative to the workspace root. * @param signal - caller cancellation. * @returns the directory's children in the backend's stable name order, bounded by the entry cap. */ -@Remote async list(agent: Agent, path: string, signal: AbortSignal): Promise +@Remote async list(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise /** - * Stream every `fs/observed` observation of a file inside the Agent's - * workspace. Only Agent filesystem operations report here; the OS is not - * watched. - * @param agent - target Agent resolved from the Session identity on the wire. + * Stream every `fs/observed` observation of a file inside the Session's + * workspace. Only instrumented filesystem operations report here; the OS is + * not watched. + * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. * @param signal - generation cancellation. * @returns `ready` once the Host observation queue is active and the workspace * root is resolved, then queued and live observations in emission order. */ -@Remote({ mode: 'stream' }) changes(agent: Agent, signal: AbortSignal): AsyncIterable +@Remote({ mode: 'stream' }) changes(workspaceFileScope: WorkspaceFileScope, signal: AbortSignal): AsyncIterable ``` -Types: [Agent](core.md) - Source: [`packages/api/workspace-files/src/index.ts`](../../packages/api/workspace-files/src/index.ts) diff --git a/docs/subsystems/workspace.zh.md b/docs/subsystems/workspace.zh.md index 3474cbf147..309bff841c 100644 --- a/docs/subsystems/workspace.zh.md +++ b/docs/subsystems/workspace.zh.md @@ -251,76 +251,74 @@ Host Remote file reads and workspace directory observations over the composed fi ```ts cordis-catalog /** * Read one page of lines from a UTF-8 file readable by the filesystem backend. - * @param agent - target Agent resolved from the Session identity on the wire. + * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. * @param path - absolute path or path relative to the workspace root; files outside it are allowed. * @param range - the line window; omitted fields take the page defaults. * @param signal - caller cancellation. * @returns the page, the file's version at the stat before it, and whether it reaches the last line. */ -@Remote async read(agent: Agent, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise +@Remote async read( workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceFileRange, signal: AbortSignal, ): Promise /** * Read one byte window of a regular file readable by the filesystem backend: raw * bytes, no text decoding and no binary rejection. - * @param agent - target Agent resolved from the Session identity on the wire. + * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. * @param path - absolute path or path relative to the workspace root; files outside it are allowed. * @param range - the byte window; omitted fields take the window defaults. * @param signal - caller cancellation. * @returns the window in base64, the file's version and size at the stat before it, and whether it reaches the last byte. */ -@Remote async readBytes(agent: Agent, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise +@Remote async readBytes( workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceByteRange, signal: AbortSignal, ): Promise /** * Read a complete regular file as bytes, subject to the configured full-file cap. - * @param agent - target Agent whose workspace resolves relative paths. + * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. * @param path - absolute or workspace-relative file path. * @param signal - caller cancellation. * @returns one complete base64 window with offset zero and eof true; oversized files fail with too-large. */ -@Remote async readAll(agent: Agent, path: string, signal: AbortSignal): Promise +@Remote async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise /** * Read a complete file relative to another file's directory, including outside the workspace. - * @param agent - Agent whose workspace resolves the base file's relative path. + * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. * @param path - base file, absolute or workspace-relative. * @param relativePath - relative filesystem path, not a URL or absolute path. * @param signal - caller cancellation. * @returns the complete related file using the ordinary file-size and access checks. */ -@Remote async readRelated(agent: Agent, path: string, relativePath: string, signal: AbortSignal): Promise +@Remote async readRelated( workspaceFileScope: WorkspaceFileScope, path: string, relativePath: string, signal: AbortSignal, ): Promise /** * Report one regular file's identity, version, and size without its content. - * @param agent - target Agent resolved from the Session identity on the wire. + * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. * @param path - absolute path or path relative to the workspace root; files outside it are allowed. * @param signal - caller cancellation. * @returns the file's absolute path, current version, and byte size. */ -@Remote async stat(agent: Agent, path: string, signal: AbortSignal): Promise +@Remote async stat(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise /** - * List the direct children of one directory inside the Agent's workspace. - * @param agent - target Agent resolved from the Session identity on the wire. + * List the direct children of one directory inside the Session's workspace. + * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. * @param path - workspace path, absolute or relative to the workspace root. * @param signal - caller cancellation. * @returns the directory's children in the backend's stable name order, bounded by the entry cap. */ -@Remote async list(agent: Agent, path: string, signal: AbortSignal): Promise +@Remote async list(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise /** - * Stream every `fs/observed` observation of a file inside the Agent's - * workspace. Only Agent filesystem operations report here; the OS is not - * watched. - * @param agent - target Agent resolved from the Session identity on the wire. + * Stream every `fs/observed` observation of a file inside the Session's + * workspace. Only instrumented filesystem operations report here; the OS is + * not watched. + * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. * @param signal - generation cancellation. * @returns `ready` once the Host observation queue is active and the workspace * root is resolved, then queued and live observations in emission order. */ -@Remote({ mode: 'stream' }) changes(agent: Agent, signal: AbortSignal): AsyncIterable +@Remote({ mode: 'stream' }) changes(workspaceFileScope: WorkspaceFileScope, signal: AbortSignal): AsyncIterable ``` -Types: [Agent](core.zh.md) - Source: [`packages/api/workspace-files/src/index.ts`](../../packages/api/workspace-files/src/index.ts) diff --git a/docs/testing.i18n.yaml b/docs/testing.i18n.yaml index 899424ae54..82fc949fe6 100644 --- a/docs/testing.i18n.yaml +++ b/docs/testing.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/testing.md -testing.md: b9800bd7aa25e2556d2fa97e9397c140fffdb442 -testing.zh.md: 59f1a7ca05f0e50f6a3999498b41670228618514 +testing.md: bbf7db5d788667dc85c34dfdac4784e2b0dccca8 +testing.zh.md: 7d386494bb8fd712b93aeeeb4f8c7b196349a1db diff --git a/docs/testing.md b/docs/testing.md index b9800bd7aa..bbf7db5d78 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -14,7 +14,7 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning - **Snapshot** (`pnpm run test:snapshot`): a top-level scenario's highest recorded parent generation supplies user input and model replay, then serves as the expected persisted result. Parent filenames are `session[.vN].jsonl`; child roles are `session.[.vN].jsonl`; v0 omits `.v0`, positive versions require lowercase `.vN`, and each filename must agree with its header. Process scenarios start through `dsh`: headless owns one-shot behavior, the SDK owns persistent control, ACP owns automation-protocol behavior, and Web retains browser/ARIA evidence beside the same Session. `snapshot.yml` declares the profile, composition/header class, recording policy, exceptional replay or input metadata, and workspace facts. Typed tokens preserve parent/child identity relationships; only header pins own prompt/schema sidecars. A mutating scenario independently compares the complete `workspace.expected/` tree, which record and refresh never rewrite. Use `test:snapshot:record` when a model transcript changes and `test:snapshot:refresh` when replay input remains valid; review every resulting diff. - **Web browser snapshot** (`pnpm run test:web`; required Linux PR gate): Chromium compares session-driven output under `snapshots/web/` and UI-only output under `apps/web/tests/expected/`. CI forces read-only `DSH_SNAPSHOT=replay`, never writing expected outputs; record/refresh stay local and every diff is reviewed ([web e2e lane](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md), [CI gate decision](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md)). `test:web` builds first for plugin CSS. -Session fixtures retain headers and payloads but omit body sequence/time envelopes; replay synthesizes them. Replay, record, and refresh select each parent/child role's highest generation. Current V3 uses `.v3`, one row per event, and embedded compact Assistant streams. Historical fixtures retain their released representation; explicit `sessionFormat` owners preserve migration coverage. Follow the [format-version cookbook](cookbook/adding-a-session-format-version.md#snapshot-successors) to add successors without changing predecessors. +Session fixtures retain headers and payloads but omit body sequence/time envelopes; replay synthesizes them. Replay, record, and refresh select each parent/child role's highest generation. Current fixtures use the [writer format](session-format-status.md) in their filenames and headers, one row per event, and embedded compact Assistant streams. Historical fixtures retain their released representation; explicit `sessionFormat` owners preserve migration coverage. Follow the [format-version cookbook](cookbook/adding-a-session-format-version.md#snapshot-successors) to add successors without changing predecessors. ## How specs execute diff --git a/docs/testing.zh.md b/docs/testing.zh.md index 59f1a7ca05..7d386494bb 100644 --- a/docs/testing.zh.md +++ b/docs/testing.zh.md @@ -14,7 +14,7 @@ - **快照**(`pnpm run test:snapshot`):顶层场景数值最高的已录制 parent generation 同时提供用户输入和模型回放,并作为持久化结果的预期值。parent 文件名是 `session[.vN].jsonl`;child 角色使用 `session.[.vN].jsonl`;v0 省略 `.v0`,正版本必须使用小写 `.vN`,且每个文件名必须与其 header 一致。进程级场景都通过 `dsh` 启动:headless 负责一次性行为,SDK 负责持久控制,ACP 负责自动化协议行为,Web 在同一 Session 旁保留浏览器与 ARIA 证据。`snapshot.yml` 声明 profile、组合与请求头类别、录制策略、例外回放或输入元数据以及 workspace 事实。带类型的 token 保留父子身份关系;只有请求头 pin 拥有 prompt/schema sidecar。变更 workspace 的场景会独立比较完整的 `workspace.expected/` 目录,record 与 refresh 绝不改写该目录。当模型 transcript(文本记录)变化时使用 `test:snapshot:record`,回放输入仍有效时使用 `test:snapshot:refresh`;请审查所有结果差异。 - **Web 浏览器快照**(`pnpm run test:web`;必需的 Linux PR(Pull Request)门禁):Chromium 比较 `snapshots/web/` 下由会话驱动的输出,以及 `apps/web/tests/expected/` 下仅含 UI 的输出。CI 强制只读的 `DSH_SNAPSHOT=replay`,绝不写入预期输出;record/refresh 留在本地,每处 diff 都须评审([web e2e 车道](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md)、[CI 门禁决策](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md))。`test:web` 会先构建以交付插件 CSS。 -Session fixture 保留 header 与 payload,但省略正文 seq/time envelope;replay 会合成这些 envelope。Replay、record 与 refresh 会选择每个 parent/child 角色的最高 generation。当前 V3 使用 `.v3`、每个事件一行,并嵌入紧凑 Assistant stream。历史 fixture 保留其已发布表示;显式 `sessionFormat` 所有者保留迁移覆盖。按照[格式版本实操手册](cookbook/adding-a-session-format-version.zh.md#snapshot-successors)添加后继代际,不改动前代。 +Session fixture 保留 header 与 payload,但省略正文 seq/time envelope;replay 会合成这些 envelope。Replay、record 与 refresh 会选择每个 parent/child 角色的最高 generation。当前 fixture 在文件名与 header 中使用[写入格式](session-format-status.zh.md),每个事件一行,并嵌入紧凑 Assistant stream。历史 fixture 保留其已发布表示;显式 `sessionFormat` 所有者保留迁移覆盖。按照[格式版本实操手册](cookbook/adding-a-session-format-version.zh.md#snapshot-successors)添加后继代际,不改动前代。 ## spec 如何被执行 diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 49f6610bd2..a9fa6c7545 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: c6c09aee8ed1cac69558bb02ca3395eaebed86e5 +tool-catalog.zh.md: 2cd6715ff03752885da0bb91e7e9ed9796f9ede4 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index c2ea95ff46..c6c09aee8e 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 files accessible through the Session filesystem 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 { @@ -239,7 +239,7 @@ Declare existing workspace files as final deliverables. The user opens the curre "properties": { "path": { "type": "string", - "description": "Path of an existing file inside the workspace." + "description": "Path of an existing regular file. Relative paths use the Session working directory." }, "description": { "type": "string", diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 41fc1eaa3c..2cd6715ff0 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -229,7 +229,7 @@ bash 工具是 bash 执行器 seam 面向模型的消费方。使用 `run_in_bac ### `present` -声明交付已有的工作区文件。用户打开当前源文件;不复制或保存其内容。调用工具前先创建文件。 +声明交付 Session 文件系统可访问的已有文件。如果你创建或更新的文件是用户要求接收的成果,则必须在写入完成后、最终回复前调用 present,包括通过 Bash 或代码执行创建的文件。在回复中提到文件路径不能替代这次调用。文件必须已存在。用户打开当前源文件;不复制或保存其内容。 ```json { @@ -243,7 +243,7 @@ bash 工具是 bash 执行器 seam 面向模型的消费方。使用 `run_in_bac "properties": { "path": { "type": "string", - "description": "Path of an existing file inside the workspace." + "description": "Path of an existing regular file. Relative paths use the Session working directory." }, "description": { "type": "string", diff --git a/package.json b/package.json index 91ca336e0f..09627ea0d6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-root", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "license": "MIT", "private": true, "type": "module", @@ -59,7 +59,6 @@ "test:expected:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.expected.config.ts", "test:approval-policy": "node --test .github/review-ownership/check-approval.test.mjs", "test:issue-management": "node .github/issue-management/policy.test.mjs", - "test:request-review": "node --test .github/review-ownership/request-review.test.mjs", "test:snapshot": "vitest run --config vitest.snapshot.config.ts", "test:snapshot:record": "DSH_SNAPSHOT=record vitest run --config vitest.snapshot.config.ts --update", "test:snapshot:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.snapshot.config.ts", diff --git a/packages/acp/acp/package.json b/packages/acp/acp/package.json index c7a821c5d5..a5cf5a2d64 100644 --- a/packages/acp/acp/package.json +++ b/packages/acp/acp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-acp", "description": "Automation-only Agent Client Protocol server for driving DeepSeek Harness agents over JSON-RPC stdio", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/api/README.i18n.yaml b/packages/api/README.i18n.yaml index 5575e88512..ec53eaf4d8 100644 --- a/packages/api/README.i18n.yaml +++ b/packages/api/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/README.md -README.md: 20455e6b5622ffd6e826b1d4b427838f96f6610f -README.zh.md: 37d6737628781ed4ba51135010302d4edf566282 +README.md: 5bc878fa53b9ad0c3795c13e9442274b98820190 +README.zh.md: 8aa364850e2378c7361807ae1065cfdd6eeaa9cc diff --git a/packages/api/README.md b/packages/api/README.md index 20455e6b56..5bc878fa53 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -31,7 +31,7 @@ The packages below provide the Remote layer; the package READMEs own the exhaust | [`session-controller/`](session-controller/README.md) | Owns Session commands, history streams, live control state, and Agent/Session identity policy. | `ctx.sessionController` / `ctx.remote.session` | | [`settings-controller/`](settings-controller/README.md) | Owns the configuration-surface reads and writes over the settings-domain seams. | `ctx.settingsController`, `ctx.credentialsController` / `ctx.remote.settings`, `ctx.remote.credentials` | | [`workspace-controller/`](workspace-controller/README.md) | Owns Workspace mutations and the complete Client Workspace projection. | `ctx.workspaceController` / `ctx.remote.workspace` | -| [`workspace-files/`](workspace-files/README.md) | Owns bounded workspace file access — `stat`, paged `read`, `list`, and the agent-write `changes` feed — and the Client `file` resource provider over it. | `ctx.workspaceFiles` / `ctx.remote.workspaceFiles` | +| [`workspace-files/`](workspace-files/README.md) | Owns bounded workspace file access — `stat`, paged `read`, `list`, and the instrumented-operation `changes` feed — and the Client `file` resource provider over it. | `ctx.workspaceFiles` / `ctx.remote.workspaceFiles` | Remote calls run Client → Host over the application's shared Connection. API Gateway owns Remote transport, while the controller packages own Session, configuration-surface, and Workspace behavior. Feature packages register exact Connection Fetch routes for responses that do not fit Remote invocation, such as streamed downloads. diff --git a/packages/api/README.zh.md b/packages/api/README.zh.md index 37d6737628..8aa364850e 100644 --- a/packages/api/README.zh.md +++ b/packages/api/README.zh.md @@ -9,7 +9,7 @@ kind: "package-group" ## 概述 -`api/` 组提供应用的 Remote 层:Client 环境可以通过类型化方法调用运行在 Host 上的业务能力——管理目标、运行命令、查看插件清单、发现文件与会话引用——并接收结果或转发的 Host 事件。`remotes` 决定暴露哪些能力,以及每次调用如何到达正确会话的 agent(智能体);`gateway` 在 Client 与 Host 之间承载调用及其结果。技术栈运行在应用共享的 Connection 之上;流式会话数据刻意不在其中。 +`api/` 组提供应用的 Remote 层:Client 环境可以调用运行在 Host 上的业务能力——管理目标、运行命令、查看插件清单、发现文件与会话引用——调用方式是类型化方法,并接收结果或转发的 Host 事件。`remotes` 决定暴露哪些能力、以及每次调用如何到达正确会话的 agent;`gateway` 在 Client 与 Host 之间承载调用及其结果。技术栈运行在应用共享的 Connection 之上;流式会话数据刻意不在其中。 ## 目录 @@ -28,12 +28,12 @@ kind: "package-group" |---|---|---| | [`remotes/`](remotes/README.zh.md) | 决定 Client 可以消费哪些 Host 能力与事件。 | — | | [`gateway/`](gateway/README.zh.md) | 承载类型化一元调用、多路复用流与转发的 Host 事件。 | `ctx.typertGateway` / `ctx.remote` | -| [`session-controller/`](session-controller/README.zh.md) | 拥有会话命令、历史记录流、实时控制状态与 agent 与会话身份策略。 | `ctx.sessionController` / `ctx.remote.session` | +| [`session-controller/`](session-controller/README.zh.md) | 拥有会话命令、历史记录流、实时控制状态与 Agent/Session 身份策略。 | `ctx.sessionController` / `ctx.remote.session` | | [`settings-controller/`](settings-controller/README.zh.md) | 拥有 settings 域各 seam 之上的配置界面读写。 | `ctx.settingsController`、`ctx.credentialsController` / `ctx.remote.settings`、`ctx.remote.credentials` | | [`workspace-controller/`](workspace-controller/README.zh.md) | 拥有 Workspace 变更与完整 Client Workspace 投影。 | `ctx.workspaceController` / `ctx.remote.workspace` | -| [`workspace-files/`](workspace-files/README.zh.md) | 拥有有界的工作区文件访问——`stat`、分页 `read`、`list` 与 agent 写入的 `changes` 流——以及其上的 Client `file` 资源提供方。 | `ctx.workspaceFiles` / `ctx.remote.workspaceFiles` | +| [`workspace-files/`](workspace-files/README.zh.md) | 拥有有界的工作区文件访问——`stat`、分页 `read`、`list` 与已埋点操作的 `changes` 流——以及其上的 Client `file` 资源提供方。 | `ctx.workspaceFiles` / `ctx.remote.workspaceFiles` | -Remote 调用沿 Client → Host 方向运行在应用共享的 Connection 之上。API Gateway 拥有 Remote 传输,各控制器包分别拥有会话、配置界面与 Workspace 行为。流式下载等不适合 Remote 调用的响应由功能包注册精确的 Connection Fetch 路由。 +Remote 调用沿 Client → Host 方向运行在应用共享的 Connection 之上。API Gateway 拥有 Remote 传输,各控制器包分别拥有 Session、配置界面与 Workspace 行为。流式下载等不适合 Remote 调用的响应由功能包注册精确的 Connection Fetch 路由。 ----- diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index 8530dfd990..8228fefce4 100644 --- a/packages/api/gateway/package.json +++ b/packages/api/gateway/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-gateway", "description": "Typert Remote Host dispatcher and Client API endpoint", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 3d91c68dc6..fafd62f2e3 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-remotes", "description": "Remote BFF assembly for application-selected Host capabilities", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, @@ -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/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 6a69e68585..f978a3928f 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/session-controller/README.md -README.md: ef6ffb35636f9f1a87c3b30832f68540d5dcd0ea -README.zh.md: e01b309e184280849cffbcdc8f4ea0a8b6bc9c96 +README.md: 24cfbba626ddec510452e3b6c8bc4333c03876bd +README.zh.md: eca188b0670bbb36746dd197566d41db4359cea3 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index ef6ffb3563..24cfbba626 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -28,7 +28,7 @@ History pages and follow opening snapshots carry one `{ type: 'event', event: Se The Client journal validates exact V3 event envelopes before publishing follow snapshots, live entries, or history pages. It reuses the browser-safe Session validators for required surface markers, exact replacement endpoints, earlier unique source seqs, embedded Assistant provenance, request-header omissions, and tool-error consistency. Invalid records fail without field stripping or normalization; range membership and source existence remain durable-log checks on the Host. -Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Queue mutation and cancellation require live state; model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. +Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Queue mutation and cancellation require live state; model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the default application. The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publishes one named settlement delta that retires the attempt's transient rows and adds the durable entry while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. For each inbox change, the Host publishes the projection frame first and derives the queue replacement from that same validated post-fold value, so listener registration order cannot produce a stale queue frame.Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index e01b309e18..eca188b067 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -24,21 +24,21 @@ kind: "package-reference" ## 使用本包 -历史页与 follow opening 快照为每个持久 Session 事件携带一条 `{ type: 'event', event: SessionWireEvent }` record。Client 把每条已接受 record 保留为一个持久 `SessionEventLikeEntry`;Assistant token 边界保留在 `assistant/message` 或 `assistant/attempt` 的紧凑流内。工具参数、结果内容、失败信息和 `tool/result.data.meta` 原样通过;控制器不解析工具定义、不运行展示转换器,也不附加 UI 数据。 +历史页与 follow opening 快照为每个持久 Session 事件 携带一条 `{ type: 'event', event: SessionWireEvent }` record。Client 把每条已接受 record 保留为一个持久 `SessionEventLikeEntry`;Assistant token 边界保留在 `assistant/message` 或 `assistant/attempt` 的紧凑流内。工具参数、结果内容、失败信息和 `tool/result.data.meta` 原样通过;控制器不解析工具定义、不运行展示转换器,也不附加 UI 数据。 -Client journal 在发布 follow 快照、live entry 或历史页之前验证精确的 V3 事件 envelope。它复用浏览器安全的 Session validator,检查必需的 surface marker、精确的 replacement endpoint、更早且唯一的 source seq、内嵌 Assistant 来源、request header 省略规则以及工具错误一致性。无效 record 直接失败,不删除字段或归一化;范围成员与来源存在性仍由 Host 的持久日志检查。 +Client journal 在发布 follow 快照、live entry 或历史页之前验证精确的 V3 事件 envelope。它复用浏览器安全的 Session validator,检查必需的 surface marker、精确的 replacement endpoint、更早且唯一的 source seq、内嵌 Assistant 来源、request header 可选字段的省略规则以及 工具错误一致性。无效 record 直接失败,不删除字段或归一化;范围成员与来源存在性仍由 Host 的持久日志检查。 -每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查持久化数据;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。queue 变更与取消要求 live 状态;模型、重命名、提示词和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。提示词准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,提示词重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。 +每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。queue 变更与取消要求 live 状态;模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。 经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时打开默认应用。 -Client 适配器提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条消息,而 `loadThrough(seq)`——轮次跳转加载器——按每页 200 条消息循环拉取直到窗口覆盖目标 seq,重复调用会降低共享目标 seq,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web 适配器显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑流,每个流成员都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且轮次与步骤匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布一个具名 settlement delta,删除该 attempt 的瞬态 row、加入持久条目,并保留同一步骤中更早的 retry。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若控制器错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的事件 seq。业务、持久化或未解决的连续性故障会终止流,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作持久事件。每次 inbox 变更时,Host 会先发布 projection frame,再从同一份已校验的折叠后值派生 queue replacement,因此监听器注册顺序不会产生陈旧的 queue frame。Client Agent 上下文提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、提示词、queue 与历史操作,不提供文件传输。 +Client 适配器提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条消息,而 `loadThrough(seq)`——轮次跳转加载器——按每页 200 条消息循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web 适配器 显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且 轮次与步骤 匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布一个具名 settlement delta,删除该 attempt 的瞬态 row、加入持久条目,并保留同一步骤中更早的 retry。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的事件 seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作持久事件。每次 inbox 变更时,Host 会先发布 projection frame,再从同一份已校验的折叠后值派生 queue replacement,因此监听器注册顺序不会产生陈旧的 queue frame。Client Agent 上下文提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。 -Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与提示词之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。回显按顺序存放图片预览与持久文件引用。Session 根据当前运行状态与请求的投递模式推导其 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。提示词的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其持久事件或 queue occurrence 后延迟一个动画帧退休,带标识的提示词失败或被放弃时立即退休,销毁时按 failed 退休。每次退休恰好触发一次 `onRetire`;因观察到对应项而退休时,还会携带有序的持久附件引用,让 composer 释放成功卡片并保留失败草稿。回显只存在于 Client 内存;刷新与重连只从持久事件重建会话。 +Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与提示词之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。回显按顺序存放图片预览与持久文件引用。Session 根据当前运行状态与请求的投递模式推导其 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。提示词的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,带标识的提示词失败或被放弃时立即退休,销毁时按 failed 退休。每次退休恰好触发一次 `onRetire`;observed 退休还会携带有序的持久附件引用,让 composer 释放成功卡片并保留失败草稿。回显只存在于 Client 内存;刷新与重连只从持久事件重建会话。 ## 会话媒体引用 -当 `connection`、`fs` 与 `attachments` 均被组合时,`SessionMediaReferences` 在鉴权 `connection.fetch` 通道上挂载 `GET|HEAD /api/file?path=<绝对路径>`。它通过 `ctx.fs` 读取普通文件,包括已注册工作区之外的临时路径与远程提供方中的文件。目录包含关系与 MIME 类别均不限制访问;`mime-types` 提供响应类型,未知扩展名使用 `application/octet-stream`。GET 复用 `readBytes` 执行读取前及读取中的字节限制;HEAD 只读取元数据。所有文件均使用 `ctx.attachments.imageLimits.maxImageBytes`(通常为 20 MiB);超过此上限返回 413。响应包含完整文件,忽略 Range,并携带 `private, no-store`、`nosniff` 与沙箱 CSP,使直接打开的 HTML/SVG 无法以 API 源身份执行脚本。客户端重写位于 `ui-chat`(`AssistantMarkdown`);音视频文件响应已可用,Markdown 音视频播放器节点仍是独立工作。 +当 `connection`、`fs` 与 `attachments` 均被组合时,`SessionMediaReferences` 在鉴权 `connection.fetch` 通道上挂载 `GET|HEAD /api/file?path=<绝对路径>`。它通过 `ctx.fs` 读取普通文件,包括已注册工作区之外的临时路径与远程提供方中的文件。目录包含关系与 MIME 类别均不限制访问;`mime-types` 提供响应类型,未知扩展名使用 `application/octet-stream`。GET 复用 `readBytes` 执行读取前及读取中的字节限制;HEAD 只读取元数据。所有文件均使用 `ctx.attachments.imageLimits.maxImageBytes`(通常为 20 MiB);超过此上限返回 413。响应包含完整文件,忽略 Range,并携带 `private, no-store`、`nosniff` 与 沙箱 CSP,使直接打开的 HTML/SVG 无法以 API 源身份执行脚本。客户端重写位于 `ui-chat`(`AssistantMarkdown`);音视频文件响应已可用,Markdown 音视频播放器节点仍是独立工作。 ----- diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index 3603490bf4..0376282f77 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-session-controller", "description": "Session Remote commands, cold reads, and live control transport", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index 9cc93be179..b5e92ec6e9 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -1,10 +1,11 @@ /** Session Remote owner: cold reads, explicit Agent commands, and live control state. */ +import { hostname } from 'node:os' import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { errorChain } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-client-file-upload' -import { canOpenNativePath, openNativePath } from '@deepseek-ai/dsh-native-command' +import { canOpenNativePath, nativeFileManager, openNativePath, revealNativePath } from '@deepseek-ai/dsh-native-command' import type { SessionId } from '@deepseek-ai/dsh-session' import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence' import type { SessionObservation } from '@deepseek-ai/dsh-session-query' @@ -76,6 +77,8 @@ export interface Config { export interface SessionControllerInternals { /** Native default-application handoff. */ readonly openPath?: (path: string, signal: AbortSignal) => Promise + /** Native file-manager handoff. */ + readonly revealPath?: (path: string, signal: AbortSignal) => Promise /** Native handoff availability probe. */ readonly canOpenPath?: () => boolean } @@ -105,6 +108,7 @@ export class SessionController extends TypertRemoteService { private readonly history: SessionHistoryController private readonly listState: ApiSessionList private readonly openPath: (path: string, signal: AbortSignal) => Promise + private readonly revealPath: (path: string, signal: AbortSignal) => Promise private readonly canOpenPath: () => boolean private readonly promotions = new Set>() @@ -132,6 +136,7 @@ export class SessionController extends TypertRemoteService { this.history = new SessionHistoryController(ctx, (observation) => { this.promote(observation) }) this.listState = new ApiSessionList(ctx) this.openPath = internals.openPath ?? openNativePath + this.revealPath = internals.revealPath ?? revealNativePath this.canOpenPath = internals.canOpenPath ?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath())) ctx.plugin(SessionFileReferences) @@ -268,6 +273,15 @@ export class SessionController extends TypertRemoteService { return this.canOpenPath() } + /** + * Describe the serving desktop for authenticated file-action routes. + * @returns Host name, configured availability, and platform-specific file-manager behavior. + */ + workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | 'explorer' | 'directory' | null } { + const fileManager = nativeFileManager() + return { name: hostname(), available: fileManager !== null && this.canOpenPath(), fileManager } + } + /** * Open one path prepared by a Session-aware caller on the Host desktop. * @param request - path after best-effort Session workspace resolution. @@ -289,7 +303,8 @@ export class SessionController extends TypertRemoteService { } signal.throwIfAborted() try { - await this.openPath(request.path, signal) + if (request.action === 'reveal') await this.revealPath(request.path, signal) + else await this.openPath(request.path, signal) return { opened: true } } catch (error: unknown) { if (signal.aborted) throw new RemoteError('gateway/cancelled', 'path open was aborted', {}) diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index 607baed3cf..60f7a93e56 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -358,6 +358,8 @@ export interface SessionCancelValue { /** Request to open one path prepared by a Session-aware caller on the Host desktop. */ export interface SessionOpenWorkspacePathRequest { + /** File-manager navigation when requested; omission uses the default application. */ + readonly action?: 'reveal' /** Path after best-effort Session workspace resolution, in Host filesystem syntax. */ readonly path: string } diff --git a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts index ee199ddcdd..eb789cb374 100644 --- a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts +++ b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts @@ -1,3 +1,4 @@ +import * as nativeCommand from '@deepseek-ai/dsh-native-command' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import SessionStore from '@deepseek-ai/dsh-session' @@ -138,3 +139,34 @@ describe('session/openWorkspacePath', () => { }) }) }) + + +it('reports Host file-manager metadata and dispatches reveal separately from default-app open', async () => { + const ctx = await context() + const revealPath = vi.fn(async (_path: string, _signal: AbortSignal) => {}) + const openPath = vi.fn(async (_path: string, _signal: AbortSignal) => {}) + const controller = createSessionTestController(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', openPath, revealPath, + }) + try { + expect(controller.workspaceDesktop()).toMatchObject({ available: true, name: expect.any(String) as string }) + const signal = new AbortController().signal + await controller.openWorkspacePath({ path: '/workspace/report.txt', action: 'reveal' }, signal) + expect(revealPath).toHaveBeenCalledWith('/workspace/report.txt', signal) + expect(openPath).not.toHaveBeenCalled() + } finally { await ctx.fiber.dispose() } +}) + +it('uses the native reveal adapter without a test override and respects unsupported desktop metadata', async () => { + const ctx = await context() + const reveal = vi.spyOn(nativeCommand, 'revealNativePath').mockResolvedValue(undefined) + const manager = vi.spyOn(nativeCommand, 'nativeFileManager').mockReturnValue(null) + try { + const controller = createSessionTestController(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: true, + }) + expect(controller.workspaceDesktop()).toMatchObject({ available: false, fileManager: null }) + await controller.openWorkspacePath({ path: '/report.txt', action: 'reveal' }, new AbortController().signal) + expect(reveal).toHaveBeenCalledOnce() + } finally { manager.mockRestore(); reveal.mockRestore(); await ctx.fiber.dispose() } +}) diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index ac992baced..721288f7dc 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -91,6 +91,7 @@ export interface TestSessionRemoteDefaults { readonly nativeOpen?: boolean readonly saveDefaultModelSelection?: (selection: AgentModelSelection) => void | Promise readonly openPath?: (path: string, signal: AbortSignal) => Promise + readonly revealPath?: (path: string, signal: AbortSignal) => Promise readonly canOpenPath?: () => boolean } @@ -284,6 +285,7 @@ function installControllers( }, { ...defaults.openPath === undefined ? {} : { openPath: defaults.openPath }, + ...defaults.revealPath === undefined ? {} : { revealPath: defaults.revealPath }, ...defaults.canOpenPath === undefined ? {} : { canOpenPath: defaults.canOpenPath }, }, ) diff --git a/packages/api/settings-controller/package.json b/packages/api/settings-controller/package.json index 2cfeb1e89a..91da932736 100644 --- a/packages/api/settings-controller/package.json +++ b/packages/api/settings-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-settings-controller", "description": "Remote owner for the configuration surfaces over the settings-domain seams", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/api/workspace-controller/package.json b/packages/api/workspace-controller/package.json index fcfd014594..ffdc9e2080 100644 --- a/packages/api/workspace-controller/package.json +++ b/packages/api/workspace-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-workspace-controller", "description": "Workspace Remote commands and reconnect-safe state transport", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/api/workspace-files/README.i18n.yaml b/packages/api/workspace-files/README.i18n.yaml index bb29de04ce..0c9f859e28 100644 --- a/packages/api/workspace-files/README.i18n.yaml +++ b/packages/api/workspace-files/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/workspace-files/README.md -README.md: e49f5bef7291233eb688bc9aeab44e56efb3d7fa -README.zh.md: 0b588e23f8d0849cad656fd00ae848f8696666ce +README.md: f0f9cb1532fa65954415e10a5e248b775cdff83a +README.zh.md: 8a510fa5a599037364095e221e7ecf6a8c7bb94a diff --git a/packages/api/workspace-files/README.md b/packages/api/workspace-files/README.md index e49f5bef72..f0f9cb1532 100644 --- a/packages/api/workspace-files/README.md +++ b/packages/api/workspace-files/README.md @@ -1,5 +1,5 @@ --- -description: "Workspace file service for the web GUI: bounded file reads through the composed filesystem, plus directory listing and Agent-write observation inside the Session workspace root." +description: "Workspace file service for the web GUI: bounded file reads through the composed filesystem, plus directory listing and instrumented filesystem observation inside the Session workspace root." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Use this package to preview files readable through a Session's filesystem from the web client. It reads UTF-8 text by page, reads bounded byte windows or complete files, resolves related files from a base file's directory, and reports file metadata. File reads may target paths outside the workspace; directory listing and Agent-write change observation remain workspace-scoped. The service exposes no mutation operation. +Use this package to preview files readable through a Session's filesystem from the web client. It reads UTF-8 text by page, reads bounded byte windows or complete files, resolves related files from a base file's directory, and reports file metadata. File reads may target paths outside the workspace; directory listing and instrumented filesystem observations remain workspace-scoped. The service exposes no mutation operation. ## Table of Contents @@ -25,7 +25,7 @@ Use this package to preview files readable through a Session's filesystem from t ## Use this package -Mount the package beside `dsh-fs`, `dsh-sandbox-policy`, and the Typert Gateway; the bundle does so right after the Session Controller. Every method takes the Session identity on the wire, so a Client calls `remote.workspaceFiles.read(agent, path, range, signal)`, `stat(agent, path, signal)`, `readBytes(agent, path, range, signal)`, `list(agent, path, signal)`, or `changes(agent, signal)` and never names a root itself. +Mount the package beside `dsh-fs`, `dsh-sandbox-policy`, the Session store, and the Typert Gateway; the bundle does so right after the Session Controller. Every method takes the Session identity on the wire, so a Client calls `remote.workspaceFiles.read(sessionId, path, range, signal)`, `stat(sessionId, path, signal)`, `readBytes(sessionId, path, range, signal)`, `list(sessionId, path, signal)`, or `changes(sessionId, signal)` and never names a root itself. The Host reads a live Session header or uses persistence `stat` for a cold Session; it does not activate an Agent, read the event body, or borrow a parent Session's root. Session persistence is optional for live reads, but without it a cold Session cannot resolve and the Gateway returns `gateway/lookup-not-found`. | Method | Returns | Purpose | |---|---|---| @@ -35,11 +35,11 @@ Mount the package beside `dsh-fs`, `dsh-sandbox-policy`, and the Typert Gateway; | `readAll(path)` | `WorkspaceFileBytes` with `offset: 0`, `eof: true` | Complete raw bytes under `maxFileBytes`; oversized files fail instead of being truncated | | `readRelated(path, relativePath)` | `WorkspaceFileBytes` | Complete bytes of a file resolved from the base file's directory on the Host | | `list(path)` | `WorkspaceDirectoryListing { path, entries, truncated }` | Direct children of one directory | -| `changes()` | stream of `WorkspaceFileWatchFrame` | Subscription readiness, then Agent observations inside the workspace root | +| `changes()` | stream of `WorkspaceFileWatchFrame` | Subscription readiness, then filesystem observations inside the workspace root | ### Addressing and paths -`read`, `readBytes`, `readAll`, `readRelated`, and `stat` accept an absolute path or one relative to the Session's workspace root. The composed filesystem decides whether the path is readable; the service does not impose workspace containment on file reads. `readRelated` resolves a relative filesystem path from the base file's directory, including when either file is outside the workspace. These methods report the file's absolute path in the filesystem's execution world. `list` remains workspace-scoped and reports the listed directory relative to that root. `changes` likewise reports only Agent observations inside the workspace root. +`read`, `readBytes`, `readAll`, `readRelated`, and `stat` accept an absolute path or one relative to the selected Session's workspace root. The composed filesystem decides whether the path is readable; the service does not impose workspace containment on file reads. `readRelated` resolves a relative filesystem path from the base file's directory, including when either file is outside the workspace. These methods report the file's absolute path in the filesystem's execution world. `list` remains workspace-scoped and reports the listed directory relative to that root. `changes` likewise reports only instrumented filesystem observations inside the workspace root. ### Pages @@ -92,7 +92,7 @@ One supervised `changes` stream serves every followed file in a Session. Followe ### Design concept -Reads through `ctx.fs` use the backend's read authority; the sandboxing backend fences writes and edits, not reads. The service adds regular-file checks and bounded transfer, while workspace containment belongs only to directory listing and change observation. A page is cut from `streamText`, which decodes and rejects non-UTF-8 chunk by chunk: the cutter counts lines before the window without keeping them, admits each in-window segment against the byte cap before buffering it, and returns at the first character past the window. One `stat` before the stream names the version and size the page reports. +Reads through `ctx.fs` use the backend's read authority; the sandboxing backend fences writes and edits, not reads. A Typert lookup derives `WorkspaceFileScope` from a live Session header or the persistence service's header-only `stat`, so cold subagent Sessions need neither Agent activation nor event-body reads. The service adds regular-file checks and bounded transfer, while workspace containment belongs only to directory listing and change observation. A page is cut from `streamText`, which decodes and rejects non-UTF-8 chunk by chunk: the cutter counts lines before the window without keeping them, admits each in-window segment against the byte cap before buffering it, and returns at the first character past the window. One `stat` before the stream names the version and size the page reports. ### Source map @@ -136,7 +136,7 @@ None; this package neither assembles nor sends a provider request. -- **Agent writes only** — `changes` relays `fs/observed` emissions; a file changed by a subprocess, a shell command, or the user's editor produces no frame. +- **Instrumented operations only** — `changes` relays `fs/observed` emissions; a file changed by a subprocess, a shell command, or the user's editor produces no frame. - **Directory scope only** — `list` and `changes` stay inside the Session workspace even though file preview reads may use any path readable by the filesystem backend. - **No total line count** — a page reports `eof`, not how many lines follow; a consumer that needs the total pages to the end or estimates from `bytes`. - **One giant line has no page** — a single line above `maxBytes` fails `too-large` at every window that includes it, because pages are cut by lines, not bytes. diff --git a/packages/api/workspace-files/README.zh.md b/packages/api/workspace-files/README.zh.md index 0b588e23f8..8a510fa5a5 100644 --- a/packages/api/workspace-files/README.zh.md +++ b/packages/api/workspace-files/README.zh.md @@ -1,5 +1,5 @@ --- -description: "面向 Web GUI 的工作区文件服务:通过组合文件系统进行有界文件读取,并在 Session 工作区根内列举目录和观察 Agent 写入。" +description: "面向 Web GUI 的工作区文件服务:通过组合文件系统进行有界文件读取,并在 Session 工作区根内列举目录和观察已埋点的文件系统操作。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -使用本包可从 Web Client 预览 Session 文件系统允许读取的文件。它按页读取 UTF-8 文本、按有界窗口或完整文件读取原始字节、从基文件目录解析关联文件,并报告文件元数据。文件读取可以指向工作区外路径;目录列举与 Agent 写入变更观察仍限定于工作区。本服务不提供修改操作。 +使用本包可从 Web Client 预览 Session 文件系统允许读取的文件。它按页读取 UTF-8 文本、按有界窗口或完整文件读取原始字节、从基文件目录解析关联文件,并报告文件元数据。文件读取可以指向工作区外路径;目录列举与已埋点的文件系统观察仍限定于工作区。本服务不提供修改操作。 ## 目录 @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -把本包与 `dsh-fs`、`dsh-sandbox-policy` 和 Typert Gateway 一起挂载;bundle 把它紧随 Session Controller 之后挂载。每个方法都在线路上携带 Session 身份,Client 调用 `remote.workspaceFiles.read(agent, path, range, signal)`、`stat(agent, path, signal)`、`readBytes(agent, path, range, signal)`、`list(agent, path, signal)` 或 `changes(agent, signal)`,从不自己指定根。 +把本包与 `dsh-fs`、`dsh-sandbox-policy`、Session store 和 Typert Gateway 一起挂载;bundle 把它紧随 Session Controller 之后挂载。每个方法都在线路上携带 Session 身份,Client 调用 `remote.workspaceFiles.read(sessionId, path, range, signal)`、`stat(sessionId, path, signal)`、`readBytes(sessionId, path, range, signal)`、`list(sessionId, path, signal)` 或 `changes(sessionId, signal)`,从不自己指定根。Host 读取 live Session header,cold Session 则使用持久层 `stat`;它不会激活 Agent、读取事件正文或借用父 Session 的根。live 读取不要求挂载 Session persistence;未挂载时 cold Session 无法解析,Gateway 返回 `gateway/lookup-not-found`。 | 方法 | 返回 | 用途 | |---|---|---| @@ -35,11 +35,11 @@ kind: "package-reference" | `readAll(path)` | `WorkspaceFileBytes`,其中 `offset: 0`、`eof: true` | `maxFileBytes` 内的完整原始字节;超大文件失败,不截断 | | `readRelated(path, relativePath)` | `WorkspaceFileBytes` | Host 从基文件目录解析出的文件的完整字节 | | `list(path)` | `WorkspaceDirectoryListing { path, entries, truncated }` | 一个目录的直接子项 | -| `changes()` | `WorkspaceFileWatchFrame` 流 | 订阅就绪确认,随后为工作区根内的 Agent 观察 | +| `changes()` | `WorkspaceFileWatchFrame` 流 | 订阅就绪确认,随后为工作区根内的文件系统观察 | ### 寻址与路径 -`read`、`readBytes`、`readAll`、`readRelated` 与 `stat` 接受绝对路径或相对于 Session 工作区根的路径。组合文件系统决定路径是否可读;本服务不额外要求文件读取限定于工作区。`readRelated` 从基文件所在目录解析相对文件系统路径,基文件或目标文件位于工作区外时同样适用。这些方法以文件系统执行环境中的绝对路径报告文件。`list` 仍限定于工作区,并以相对于该根的路径报告被列举目录。`changes` 同样只报告工作区根内的 Agent 观察。 +`read`、`readBytes`、`readAll`、`readRelated` 与 `stat` 接受绝对路径或相对于所选 Session 工作区根的路径。组合文件系统决定路径是否可读;本服务不额外要求文件读取限定于工作区。`readRelated` 从基文件所在目录解析相对文件系统路径,基文件或目标文件位于工作区外时同样适用。这些方法以文件系统执行环境中的绝对路径报告文件。`list` 仍限定于工作区,并以相对于该根的路径报告被列举目录。`changes` 同样只报告工作区根内已埋点的文件系统观察。 ### 分页 @@ -80,7 +80,7 @@ kind: "package-reference" 提供方等到 Host 的 `ready` 帧后才发首次 `stat`,读取期间将变更排队,随后将跟随者绑定到 `stat.absolutePath`。排队与实时变更都按该 Host 返回路径匹配。新的写入版本更新元数据并保留最近的字节大小;重复版本被忽略。消失通知会重新 stat 文件。stat 失败后仍跟随地址,后续写入可使其恢复;首次成功绑定路径前,Session 内任何写入都可触发重试。帧是 `RemoteResult` 值,编程异常不被捕获。 -每个 Session 的所有被跟随文件共用一条受监督的 `changes` 流。跟随者按反斜杠归一为斜杠的绝对路径匹配。载体掉线由 Gateway 监督器重连;Host 结束或终态失败的流会结束其跟随者,最后的元数据仍可读取,直到重新打开。最后一个跟随者离开时释放流,后继流等待该释放完成,插件拆除等待所有在途关闭。提供方声明 `ResourceProtocolMap.file`;文本预览声明其 Sidebar 行号导航参数。 +每个 Session 的所有被跟随文件共用一条受监督的 `changes` 流。跟随者按反斜杠归一为斜杠的绝对路径匹配。载体掉线由 Gateway 监督器重连;Host 结束或终态失败的流会结束其跟随者,最后的元数据仍可读取,直到重新打开。最后一个跟随者离开时释放流,后继流等待该释放完成,插件拆除等待所有在途关闭。提供者声明 `ResourceProtocolMap.file`;文本预览声明其 Sidebar 行号导航参数。 ----- @@ -92,7 +92,7 @@ kind: "package-reference" ### 设计概念 -经 `ctx.fs` 的读取使用后端的读取权限;沙箱后端限制写与编辑,而不限制读取。本服务增加普通文件检查与有界传输,工作区包含要求只属于目录列举与变更观察。页从 `streamText` 切出,后者逐块解码并拒绝非 UTF-8:切页器对窗口之前的行只计数不保留,对窗口内的每个片段先按字节上限验收再缓冲,并在窗口之后的第一个字符处返回。流之前的一次 `stat` 给出页所报告的版本与大小。 +经 `ctx.fs` 的读取使用后端的读取权限;沙箱后端限制写与编辑,而不限制读取。Typert lookup 从 live Session header 或持久层的 header-only `stat` 导出 `WorkspaceFileScope`,所以 cold subagent Session 不需要激活 Agent 或读取事件正文。本服务增加普通文件检查与有界传输,工作区包含要求只属于目录列举与变更观察。页从 `streamText` 切出,后者逐块解码并拒绝非 UTF-8:切页器对窗口之前的行只计数不保留,对窗口内的每个片段先按字节上限验收再缓冲,并在窗口之后的第一个字符处返回。流之前的一次 `stat` 给出页所报告的版本与大小。 ### 源码地图 @@ -136,7 +136,7 @@ Typert 生成 `./typert` 与 `./remote` 暴露的 Host 与 Client Remote 产物 -- **仅覆盖 Agent 写入**——`changes` 转发 `fs/observed` 的发射;子进程、shell 命令或用户编辑器改动的文件不产生任何帧。 +- **仅覆盖已埋点操作**——`changes` 转发 `fs/observed` 的发射;子进程、shell 命令或用户编辑器改动的文件不产生任何帧。 - **仅目录受限**——尽管文件预览可以读取文件系统后端允许的任意路径,`list` 与 `changes` 仍限定在 Session 工作区内。 - **没有总行数**——页只报告 `eof`,不报告后面还有多少行;需要总数的消费方要翻到末尾或按 `bytes` 估算。 - **超长单行没有页**——超过 `maxBytes` 的单行在包含它的每个窗口都以 `too-large` 失败,因为页按行而非按字节切。 diff --git a/packages/api/workspace-files/package.json b/packages/api/workspace-files/package.json index ab886d9e57..5ee4f54e67 100644 --- a/packages/api/workspace-files/package.json +++ b/packages/api/workspace-files/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-workspace-files", "description": "Workspace file service and Client resource provider: bounded reads, directory listing, and live metadata over the workspaceFiles Remote namespace", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, @@ -62,13 +62,13 @@ }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-client-resources": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-util-workspace-path": "workspace:^" }, "files": [ diff --git a/packages/api/workspace-files/src/changes.ts b/packages/api/workspace-files/src/changes.ts index 6a1bd7a864..ccbcc25684 100644 --- a/packages/api/workspace-files/src/changes.ts +++ b/packages/api/workspace-files/src/changes.ts @@ -1,8 +1,8 @@ /** * Producer of the `changes` stream: every `fs/observed` emission whose target * lies inside a generation's workspace root becomes one frame of that - * generation. Observations are emitted by tools after their own filesystem - * operation, so the feed covers Agent writes only; the OS is not watched. + * generation. Instrumented filesystem operations emit these observations; the + * operating system is not watched. * Each generation acknowledges its observation queue and resolved workspace * root with `ready` before emitting any queued or live changes. */ diff --git a/packages/api/workspace-files/src/index.ts b/packages/api/workspace-files/src/index.ts index 71f31a0792..0205186879 100644 --- a/packages/api/workspace-files/src/index.ts +++ b/packages/api/workspace-files/src/index.ts @@ -1,12 +1,14 @@ /** * Workspace file service: read-only file previews, workspace directory - * listings, and the agent-write change feed, exposed as `workspaceFiles`. + * listings, and the filesystem-observation change feed, exposed as + * `workspaceFiles`. * * File reads follow the composed filesystem's read access, including paths - * outside the workspace. The Session's policy supplies the base for relative - * paths, not a read-containment restriction. Directory listings and change - * observations remain workspace-scoped. File-kind checks and configured read - * caps apply to every preview; this service exposes no mutations. + * outside the workspace. The selected Session header supplies the base for + * relative paths, with the sandbox policy root as its no-cwd fallback, not a + * read-containment restriction. Directory listings and change observations + * remain workspace-scoped. File-kind checks and configured read caps apply to + * every preview; this service exposes no mutations. * * A page is cut from `streamText`, which decodes and rejects non-UTF-8 as it * goes, so the file is read only up to the first character past the page and @@ -20,11 +22,13 @@ import { posix, win32 } from 'node:path' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import type { Agent } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-fs' import type { FsDirEntry, FsInfo, FsPathInfo, FsTarget } from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-sandbox-policy' -import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import type {} from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-persistence' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { Remote, RemoteError, TypertRemoteService, type TypertLookup } from '@deepseek-ai/dsh-typert-protocol' import { WorkspaceChangeFeed } from './changes.ts' import type { WorkspaceByteRange, @@ -46,6 +50,21 @@ declare module '@deepseek-ai/cordis' { } } +/** Header-derived file resolution context for one Session identity. */ +export interface WorkspaceFileScope { + /** Session identity received on the wire. */ + readonly sessionId: SessionId + /** Session workspace root, or the deployment fallback when its header has no cwd. */ + readonly workspaceRoot: string +} + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface TypertLookupMap { + /** Resolve a Session id to its workspace root without loading its event body or activating an Agent. */ + workspaceFileScope: TypertLookup + } +} + /** Deployment caps on one page or one listing. */ export interface Config { /** @@ -161,7 +180,7 @@ function directoryEntry(child: FsDirEntry): WorkspaceDirectoryEntry { /** Host Remote file reads and workspace directory observations over the composed filesystem. */ export class WorkspaceFiles extends TypertRemoteService { - static inject = ['fs', 'sandboxPolicy', 'typert'] + static inject = ['fs', 'sandboxPolicy', 'sessions', 'typert'] static Config: z = z.object({ maxBytes: z.number().step(1).min(1).default(2 * 1024 * 1024), @@ -179,20 +198,45 @@ export class WorkspaceFiles extends TypertRemoteService { constructor(ctx: Context, private readonly config: Config) { super(ctx, 'workspaceFiles') this.feed = new WorkspaceChangeFeed(ctx) + ctx.inject(['sessions', 'typert'], (scope) => { + scope.typert.lookups.register('workspaceFileScope', { + parameter: 'workspaceFileScope', + wire: 'workspaceFileScopeId', + hostTypeSymbol: '@deepseek-ai/dsh-api-workspace-files#WorkspaceFileScope', + wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + resolve: async (sessionId) => { + const live = scope.sessions.get(sessionId)?.header + const stored = live === undefined + ? await scope.get('sessionPersistence')?.stat(sessionId) + : undefined + const header = live ?? stored?.header + if (header === undefined) return undefined + return { + sessionId, + workspaceRoot: header.cwd ?? scope.sandboxPolicy.workspaceRoot, + } + }, + }) + }) } /** * Read one page of lines from a UTF-8 file readable by the filesystem backend. - * @param agent - target Agent resolved from the Session identity on the wire. + * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. * @param path - absolute path or path relative to the workspace root; files outside it are allowed. * @param range - the line window; omitted fields take the page defaults. * @param signal - caller cancellation. * @returns the page, the file's version at the stat before it, and whether it reaches the last line. */ @Remote - async read(agent: Agent, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise { + async read( + workspaceFileScope: WorkspaceFileScope, + path: string, + range: WorkspaceFileRange, + signal: AbortSignal, + ): Promise { const { offset, limit } = this.resolvePage(range) - const { target, info } = await this.locateFile(agent, path, signal) + const { target, info } = await this.locateFile(workspaceFileScope, path, signal) const page = await this.cutPage(target, offset, limit, signal, path) if (page.text.includes(NUL)) { throw new RemoteError('workspace-file/not-text', `"${path}" contains NUL bytes`, { path }) @@ -203,16 +247,21 @@ export class WorkspaceFiles extends TypertRemoteService { /** * Read one byte window of a regular file readable by the filesystem backend: raw * bytes, no text decoding and no binary rejection. - * @param agent - target Agent resolved from the Session identity on the wire. + * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. * @param path - absolute path or path relative to the workspace root; files outside it are allowed. * @param range - the byte window; omitted fields take the window defaults. * @param signal - caller cancellation. * @returns the window in base64, the file's version and size at the stat before it, and whether it reaches the last byte. */ @Remote - async readBytes(agent: Agent, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise { + async readBytes( + workspaceFileScope: WorkspaceFileScope, + path: string, + range: WorkspaceByteRange, + signal: AbortSignal, + ): Promise { const { offset, length } = this.resolveWindow(range, path) - const { target, info } = await this.locateFile(agent, path, signal) + const { target, info } = await this.locateFile(workspaceFileScope, path, signal) const data = await this.ctx.fs.readByteRange(target, { offset, length }, signal) const eof = info.size === undefined ? data.length < length : offset + data.length >= info.size return { ...this.statOf(target, info), offset, data: Buffer.from(data).toString('base64'), eof } @@ -220,14 +269,14 @@ export class WorkspaceFiles extends TypertRemoteService { /** * Read a complete regular file as bytes, subject to the configured full-file cap. - * @param agent - target Agent whose workspace resolves relative paths. + * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. * @param path - absolute or workspace-relative file path. * @param signal - caller cancellation. * @returns one complete base64 window with offset zero and eof true; oversized files fail with too-large. */ @Remote - async readAll(agent: Agent, path: string, signal: AbortSignal): Promise { - const { target, info } = await this.locateFile(agent, path, signal) + async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise { + const { target, info } = await this.locateFile(workspaceFileScope, path, signal) const limit = this.config.maxFileBytes if (info.size !== undefined && info.size > limit) { throw new RemoteError('workspace-file/too-large', `"${path}" exceeds the ${limit} byte full-file cap`, { path, limit }) @@ -241,47 +290,52 @@ export class WorkspaceFiles extends TypertRemoteService { /** * Read a complete file relative to another file's directory, including outside the workspace. - * @param agent - Agent whose workspace resolves the base file's relative path. + * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. * @param path - base file, absolute or workspace-relative. * @param relativePath - relative filesystem path, not a URL or absolute path. * @param signal - caller cancellation. * @returns the complete related file using the ordinary file-size and access checks. */ @Remote - async readRelated(agent: Agent, path: string, relativePath: string, signal: AbortSignal): Promise { + async readRelated( + workspaceFileScope: WorkspaceFileScope, + path: string, + relativePath: string, + signal: AbortSignal, + ): Promise { const relative = relativePath.replace(/\\/g, '/') if (relative.length === 0 || relative.startsWith('/') || /^[a-z][a-z\d+.-]*:/iu.test(relative) || relative.includes(NUL)) { throw new RemoteError('gateway/bad-request', 'relativePath must be a relative filesystem path', {}) } - const { target } = await this.locateFile(agent, path, signal) + const { target } = await this.locateFile(workspaceFileScope, path, signal) const absolute = this.ctx.fs.processPath(target) const paths = absolute.startsWith('/') ? posix : win32 - return this.readAll(agent, paths.resolve(paths.dirname(absolute), relative), signal) + return this.readAll(workspaceFileScope, paths.resolve(paths.dirname(absolute), relative), signal) } /** * Report one regular file's identity, version, and size without its content. - * @param agent - target Agent resolved from the Session identity on the wire. + * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. * @param path - absolute path or path relative to the workspace root; files outside it are allowed. * @param signal - caller cancellation. * @returns the file's absolute path, current version, and byte size. */ @Remote - async stat(agent: Agent, path: string, signal: AbortSignal): Promise { - const { target, info } = await this.locateFile(agent, path, signal) + async stat(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise { + const { target, info } = await this.locateFile(workspaceFileScope, path, signal) return this.statOf(target, info) } /** - * List the direct children of one directory inside the Agent's workspace. - * @param agent - target Agent resolved from the Session identity on the wire. + * List the direct children of one directory inside the Session's workspace. + * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. * @param path - workspace path, absolute or relative to the workspace root. * @param signal - caller cancellation. * @returns the directory's children in the backend's stable name order, bounded by the entry cap. */ @Remote - async list(agent: Agent, path: string, signal: AbortSignal): Promise { - const { root, workspaceRoot, entry } = await this.inspect(agent, path, signal) + async list(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise { + const { root, workspaceRoot, entry } = await this.inspect(workspaceFileScope, path, signal) if (entry.type !== 'directory') { throw new RemoteError( 'workspace-file/not-directory', @@ -299,17 +353,17 @@ export class WorkspaceFiles extends TypertRemoteService { } /** - * Stream every `fs/observed` observation of a file inside the Agent's - * workspace. Only Agent filesystem operations report here; the OS is not - * watched. - * @param agent - target Agent resolved from the Session identity on the wire. + * Stream every `fs/observed` observation of a file inside the Session's + * workspace. Only instrumented filesystem operations report here; the OS is + * not watched. + * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. * @param signal - generation cancellation. * @returns `ready` once the Host observation queue is active and the workspace * root is resolved, then queued and live observations in emission order. */ @Remote({ mode: 'stream' }) - changes(agent: Agent, signal: AbortSignal): AsyncIterable { - return this.feed.follow(this.workspaceRootOf(agent), signal) + changes(workspaceFileScope: WorkspaceFileScope, signal: AbortSignal): AsyncIterable { + return this.feed.follow(workspaceFileScope.workspaceRoot, signal) } /** Apply the page defaults and caps here, so the request never carries them implicitly. */ @@ -338,29 +392,17 @@ export class WorkspaceFiles extends TypertRemoteService { } return { offset, length } } - - - /** - * The workspace root comes from the policy, not from the backend's own cwd - * default: the `minimal` preset shadows the host provider with a bare - * `fs-local` whose cwd differs, and resolving explicitly makes the answer - * the same whichever instance answers. - */ - private workspaceRootOf(agent: Agent): string { - return this.ctx.sandboxPolicy.resolve({ session: agent.session }).workspaceRoot - } - /** * Inspect the requested path itself before resolution follows its final * component. Directory containment is checked separately by `list`. */ private async inspect( - agent: Agent, + workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal, ): Promise<{ root: FsTarget; workspaceRoot: string; entry: FsPathInfo }> { if (path.length === 0) throw new RemoteError('gateway/bad-request', 'path is required', {}) - const workspaceRoot = this.workspaceRootOf(agent) + const { workspaceRoot } = workspaceFileScope const root = await this.ctx.fs.resolve(workspaceRoot, { signal }) // Gate on the path itself before anything follows it. const entry = await this.ctx.fs.lstat(path, { cwd: workspaceRoot }, signal) @@ -384,8 +426,12 @@ export class WorkspaceFiles extends TypertRemoteService { * and size. The stat re-checks what `lstat` saw: the file may have gone or * changed kind in between. */ - private async locateFile(agent: Agent, path: string, signal: AbortSignal): Promise<{ target: FsTarget; info: FsInfo }> { - const { workspaceRoot, entry } = await this.inspect(agent, path, signal) + private async locateFile( + workspaceFileScope: WorkspaceFileScope, + path: string, + signal: AbortSignal, + ): Promise<{ target: FsTarget; info: FsInfo }> { + const { workspaceRoot, entry } = await this.inspect(workspaceFileScope, path, signal) if (entry.type !== 'file') { throw new RemoteError('workspace-file/not-regular-file', `"${path}" is a ${entry.type}`, { path, kind: entry.type }) } diff --git a/packages/api/workspace-files/src/types.ts b/packages/api/workspace-files/src/types.ts index 697c9bd1f8..93b3725775 100644 --- a/packages/api/workspace-files/src/types.ts +++ b/packages/api/workspace-files/src/types.ts @@ -115,9 +115,9 @@ export interface WorkspaceDirectoryListing { } /** - * One observation of a workspace file made by an Agent's own filesystem - * operation. Frames report observations, not deltas: a consumer already holding - * `version` learns nothing new from the frame and can ignore it. + * One observation of a workspace file made by an instrumented filesystem + * operation. Frames report observations, not deltas: a consumer already + * holding `version` learns nothing new from the frame and can ignore it. */ export type WorkspaceFileChange = | { diff --git a/packages/api/workspace-files/tests/changes.spec.ts b/packages/api/workspace-files/tests/changes.spec.ts index b21b92e8db..1eaa43cc5e 100644 --- a/packages/api/workspace-files/tests/changes.spec.ts +++ b/packages/api/workspace-files/tests/changes.spec.ts @@ -6,7 +6,7 @@ import type { FsObservation } from '@deepseek-ai/dsh-fs' import { FsVersion } from '@deepseek-ai/dsh-fs' import { WorkspaceFiles } from '../src/index.ts' import type { WorkspaceFileWatchFrame } from '../src/types.ts' -import { agent, openWorkspace, type Harness } from './harness.ts' +import { openWorkspace, type Harness } from './harness.ts' let harness: Harness const closeStreams: Array<() => Promise> = [] @@ -38,7 +38,7 @@ function open( service: WorkspaceFiles, controller = new AbortController(), ): { next(): Promise>; controller: AbortController } { - const iterator = service.changes(agent, controller.signal)[Symbol.asyncIterator]() + const iterator = service.changes(harness.scope, controller.signal)[Symbol.asyncIterator]() closeStreams.push(async () => { controller.abort() await iterator.return?.() @@ -245,7 +245,7 @@ describe('workspaceFiles.changes — ending', () => { it('stops delivering to a generation the consumer returned from', async () => { const service = harness.endpoint() const controller = new AbortController() - const iterator = service.changes(agent, controller.signal)[Symbol.asyncIterator]() + const iterator = service.changes(harness.scope, controller.signal)[Symbol.asyncIterator]() closeStreams.push(async () => { controller.abort() await iterator.return?.() diff --git a/packages/api/workspace-files/tests/harness.ts b/packages/api/workspace-files/tests/harness.ts index 17872a7875..cbb73c8c9d 100644 --- a/packages/api/workspace-files/tests/harness.ts +++ b/packages/api/workspace-files/tests/harness.ts @@ -12,13 +12,15 @@ import { mkdir, mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' +import { SessionId } from '@deepseek-ai/dsh-session/types' import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' -import { WorkspaceFiles, type Config } from '../src/index.ts' +import { WorkspaceFiles, type Config, type WorkspaceFileScope } from '../src/index.ts' -/** The Agent shape the service reads: only its session reaches the policy. */ -export const agent = { id: 'a-test', session: { id: 's-test' } } as unknown as Agent +/** Build the header-derived scope that direct service calls receive after Typert lookup. */ +function fileScope(workspaceRoot: string): WorkspaceFileScope { + return { sessionId: SessionId('s-test'), workspaceRoot } +} export const signal = (): AbortSignal => new AbortController().signal @@ -27,6 +29,7 @@ export interface Harness { readonly workspace: string readonly outside: string readonly ctx: Context + readonly scope: WorkspaceFileScope /** * The service under test, at the given caps. One per test: the service key is * global to the Context, so a second call with caps is a defect in the test. @@ -49,14 +52,16 @@ export async function openWorkspace(prefix: string): Promise { await mkdir(outside, { recursive: true }) const ctx = new Context() const fiber = await ctx.plugin(LocalFileSystem, { cwd: workspace }) - // The policy is the service's only source for the workspace root, so the - // fake supplies exactly that and nothing else. - ctx.provide('sandboxPolicy', { resolve: () => ({ mode: 'workspace-write', workspaceRoot: workspace }) } as never) + ctx.provide('sandboxPolicy', { + workspaceRoot: workspace, + resolve: () => ({ mode: 'workspace-write', workspaceRoot: workspace }), + } as never) let service: WorkspaceFiles | undefined return { workspace, outside, ctx, + scope: fileScope(workspace), endpoint: (caps) => { if (service !== undefined) { if (caps !== undefined) throw new Error('the harness serves one WorkspaceFiles per test; hoist the endpoint') diff --git a/packages/api/workspace-files/tests/list.spec.ts b/packages/api/workspace-files/tests/list.spec.ts index cb6fc4538f..6cadad6ce3 100644 --- a/packages/api/workspace-files/tests/list.spec.ts +++ b/packages/api/workspace-files/tests/list.spec.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { mkdir, symlink, writeFile } from 'node:fs/promises' import { join } from 'node:path' -import { agent, failureOf, openWorkspace, signal, type Harness } from './harness.ts' +import { failureOf, openWorkspace, signal, type Harness } from './harness.ts' let harness: Harness let workspace: string @@ -25,7 +25,7 @@ describe('workspaceFiles.list — the happy path', () => { await mkdir(join(workspace, 'src')) await writeFile(join(workspace, 'notes.txt'), 'hello', 'utf8') await writeFile(join(workspace, '.hidden'), '', 'utf8') - const listing = await endpoint().list(agent, '.', signal()) + const listing = await endpoint().list(harness.scope, '.', signal()) expect(listing.path).toBe('') expect(listing.truncated).toBe(false) expect(listing.entries).toEqual([ @@ -36,7 +36,7 @@ describe('workspaceFiles.list — the happy path', () => { }) it('accepts the absolute workspace root and reports the same empty path', async () => { - const listing = await endpoint().list(agent, workspace, signal()) + const listing = await endpoint().list(harness.scope, workspace, signal()) expect(listing.path).toBe('') expect(listing.entries).toEqual([]) }) @@ -44,7 +44,7 @@ describe('workspaceFiles.list — the happy path', () => { it('reports a nested directory as its `/`-joined path relative to the root, decoded', async () => { await mkdir(join(workspace, 'src', 'my dir', '子目录'), { recursive: true }) await writeFile(join(workspace, 'src', 'my dir', '子目录', 'a.ts'), '', 'utf8') - const listing = await endpoint().list(agent, 'src/my dir/子目录', signal()) + const listing = await endpoint().list(harness.scope, 'src/my dir/子目录', signal()) expect(listing.path).toBe('src/my dir/子目录') expect(listing.entries.map(entry => entry.name)).toEqual(['a.ts']) }) @@ -55,7 +55,7 @@ describe('workspaceFiles.list — the happy path', () => { await symlink(join(workspace, 'real.txt'), join(workspace, 'to-file')) await symlink(join(workspace, 'dir'), join(workspace, 'to-dir')) await symlink(join(workspace, 'missing'), join(workspace, 'dangling')) - const listing = await endpoint().list(agent, '.', signal()) + const listing = await endpoint().list(harness.scope, '.', signal()) expect(listing.entries).toEqual([ { name: 'dangling', type: 'other' }, { name: 'dir', type: 'directory' }, @@ -69,14 +69,14 @@ describe('workspaceFiles.list — the happy path', () => { describe('workspaceFiles.list — the entry cap', () => { it('cuts at the cap in name order and says so', async () => { for (const name of ['a', 'b', 'c', 'd', 'e']) await writeFile(join(workspace, name), '', 'utf8') - const listing = await endpoint({ maxEntries: 2 }).list(agent, '.', signal()) + const listing = await endpoint({ maxEntries: 2 }).list(harness.scope, '.', signal()) expect(listing.entries.map(entry => entry.name)).toEqual(['a', 'b']) expect(listing.truncated).toBe(true) }) it('does not report a cut at exactly the cap', async () => { for (const name of ['a', 'b']) await writeFile(join(workspace, name), '', 'utf8') - const listing = await endpoint({ maxEntries: 2 }).list(agent, '.', signal()) + const listing = await endpoint({ maxEntries: 2 }).list(harness.scope, '.', signal()) expect(listing.entries).toHaveLength(2) expect(listing.truncated).toBe(false) }) @@ -84,36 +84,36 @@ describe('workspaceFiles.list — the entry cap', () => { describe('workspaceFiles.list — gates', () => { it('rejects an absolute directory outside the workspace', async () => { - const failure = await failureOf(endpoint().list(agent, outside, signal())) + const failure = await failureOf(endpoint().list(harness.scope, outside, signal())) expect(failure.code).toBe('workspace-file/outside-workspace') }) it('rejects a traversal that climbs out of the workspace', async () => { - const failure = await failureOf(endpoint().list(agent, '..', signal())) + const failure = await failureOf(endpoint().list(harness.scope, '..', signal())) expect(failure.code).toBe('workspace-file/outside-workspace') }) it('rejects a symlinked directory before following it, wherever it points', async () => { await symlink(outside, join(workspace, 'escape')) - const failure = await failureOf(endpoint().list(agent, 'escape', signal())) + const failure = await failureOf(endpoint().list(harness.scope, 'escape', signal())) expect(failure.code).toBe('workspace-file/not-directory') expect(failure.details).toMatchObject({ kind: 'symlink' }) }) it('rejects a file, which has no children to list', async () => { await writeFile(join(workspace, 'notes.txt'), 'hello', 'utf8') - const failure = await failureOf(endpoint().list(agent, 'notes.txt', signal())) + const failure = await failureOf(endpoint().list(harness.scope, 'notes.txt', signal())) expect(failure.code).toBe('workspace-file/not-directory') expect(failure.details).toMatchObject({ path: 'notes.txt', kind: 'file' }) }) it('reports a missing path as not found', async () => { - const failure = await failureOf(endpoint().list(agent, 'nope', signal())) + const failure = await failureOf(endpoint().list(harness.scope, 'nope', signal())) expect(failure.code).toBe('workspace-file/not-found') }) it('refuses an empty path as a bad request', async () => { - const failure = await failureOf(endpoint().list(agent, '', signal())) + const failure = await failureOf(endpoint().list(harness.scope, '', signal())) expect(failure.code).toBe('gateway/bad-request') }) }) diff --git a/packages/api/workspace-files/tests/read-all.spec.ts b/packages/api/workspace-files/tests/read-all.spec.ts index 6458c341cd..bf331b8f19 100644 --- a/packages/api/workspace-files/tests/read-all.spec.ts +++ b/packages/api/workspace-files/tests/read-all.spec.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdir, symlink, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { FsVersion } from '@deepseek-ai/dsh-fs' -import { agent, failureOf, openWorkspace, signal, type Harness } from './harness.ts' +import { failureOf, openWorkspace, signal, type Harness } from './harness.ts' let harness: Harness @@ -14,21 +14,21 @@ describe('workspaceFiles.readAll', () => { it('reads the complete bytes independently of the window cap', async () => { const bytes = Buffer.from([0, 255, 1, 2]) await writeFile(join(harness.workspace, 'file.bin'), bytes) - const result = await harness.endpoint({ maxBytes: 1, maxFileBytes: 4 }).readAll(agent, 'file.bin', signal()) + const result = await harness.endpoint({ maxBytes: 1, maxFileBytes: 4 }).readAll(harness.scope, 'file.bin', signal()) expect(Buffer.from(result.data, 'base64')).toEqual(bytes) expect(result).toMatchObject({ offset: 0, eof: true, bytes: 4 }) }) it('returns an empty complete file', async () => { await writeFile(join(harness.workspace, 'empty'), '') - expect(await harness.endpoint().readAll(agent, 'empty', signal())).toMatchObject({ data: '', offset: 0, eof: true, bytes: 0 }) + expect(await harness.endpoint().readAll(harness.scope, 'empty', signal())).toMatchObject({ data: '', offset: 0, eof: true, bytes: 0 }) }) it.each(['workspace', 'outside'] as const)('rejects a known oversized %s file before reading bytes', async (location) => { const path = join(harness[location], 'large') await writeFile(path, 'abcde') const read = vi.spyOn(harness.ctx.fs, 'readByteRange') - expect(await failureOf(harness.endpoint({ maxFileBytes: 4 }).readAll(agent, path, signal()))) + expect(await failureOf(harness.endpoint({ maxFileBytes: 4 }).readAll(harness.scope, path, signal()))) .toEqual({ code: 'workspace-file/too-large', details: { path, limit: 4 } }) expect(read).not.toHaveBeenCalled() }) @@ -36,7 +36,7 @@ describe('workspaceFiles.readAll', () => { it.each([undefined, 1])('checks the actual bytes when stat reports %s', async (size) => { await writeFile(join(harness.workspace, 'growing'), 'abcde') vi.spyOn(harness.ctx.fs, 'stat').mockResolvedValue({ type: 'file', version: FsVersion('v'), ...size === undefined ? {} : { size } }) - expect((await failureOf(harness.endpoint({ maxFileBytes: 4 }).readAll(agent, 'growing', signal()))).code) + expect((await failureOf(harness.endpoint({ maxFileBytes: 4 }).readAll(harness.scope, 'growing', signal()))).code) .toBe('workspace-file/too-large') }) @@ -44,9 +44,9 @@ describe('workspaceFiles.readAll', () => { await mkdir(join(harness.workspace, 'directory')) await writeFile(join(harness.outside, 'outside'), 'outside') const files = harness.endpoint() - expect((await failureOf(files.readAll(agent, 'missing', signal()))).code).toBe('workspace-file/not-found') - expect((await failureOf(files.readAll(agent, 'directory', signal()))).code).toBe('workspace-file/not-regular-file') - const outside = await files.readAll(agent, join(harness.outside, 'outside'), signal()) + expect((await failureOf(files.readAll(harness.scope, 'missing', signal()))).code).toBe('workspace-file/not-found') + expect((await failureOf(files.readAll(harness.scope, 'directory', signal()))).code).toBe('workspace-file/not-regular-file') + const outside = await files.readAll(harness.scope, join(harness.outside, 'outside'), signal()) expect(Buffer.from(outside.data, 'base64').toString()).toBe('outside') }) }) @@ -58,25 +58,25 @@ describe('workspaceFiles.readRelated', () => { await writeFile(join(harness.workspace, 'nested/near.txt'), 'near') await writeFile(join(harness.workspace, 'root.txt'), 'root') const files = harness.endpoint() - const near = await files.readRelated(agent, 'nested/base.txt', './near.txt', signal()) - const root = await files.readRelated(agent, 'nested/base.txt', '../root.txt', signal()) + const near = await files.readRelated(harness.scope, 'nested/base.txt', './near.txt', signal()) + const root = await files.readRelated(harness.scope, 'nested/base.txt', '../root.txt', signal()) expect(Buffer.from(near.data, 'base64').toString()).toBe('near') expect(Buffer.from(root.data, 'base64').toString()).toBe('root') - const fromRoot = await files.readRelated(agent, 'root.txt', 'nested\\near.txt', signal()) + const fromRoot = await files.readRelated(harness.scope, 'root.txt', 'nested\\near.txt', signal()) expect(Buffer.from(fromRoot.data, 'base64').toString()).toBe('near') }) it.each(['', '/outside', 'C:\\outside', '\\\\host\\share', 'https://example.test/a.js', 'bad\0path'])('rejects non-relative path %j', async (path) => { - expect((await failureOf(harness.endpoint().readRelated(agent, 'base', path, signal()))).code).toBe('gateway/bad-request') + expect((await failureOf(harness.endpoint().readRelated(harness.scope, 'base', path, signal()))).code).toBe('gateway/bad-request') }) it('resolves related files on either side of the workspace root', async () => { await writeFile(join(harness.workspace, 'base'), 'base') await writeFile(join(harness.outside, 'outside'), 'outside') const files = harness.endpoint() - expect((await failureOf(files.readRelated(agent, 'missing', 'file', signal()))).code).toBe('workspace-file/not-found') - const fromOutside = await files.readRelated(agent, join(harness.outside, 'outside'), '../workspace/base', signal()) - const toOutside = await files.readRelated(agent, 'base', '../outside/outside', signal()) + expect((await failureOf(files.readRelated(harness.scope, 'missing', 'file', signal()))).code).toBe('workspace-file/not-found') + const fromOutside = await files.readRelated(harness.scope, join(harness.outside, 'outside'), '../workspace/base', signal()) + const toOutside = await files.readRelated(harness.scope, 'base', '../outside/outside', signal()) expect(Buffer.from(fromOutside.data, 'base64').toString()).toBe('base') expect(Buffer.from(toOutside.data, 'base64').toString()).toBe('outside') }) @@ -86,7 +86,7 @@ describe('workspaceFiles.readRelated', () => { const base = join(harness.outside, 'space # assets', 'page.html') await writeFile(base, '') await writeFile(join(harness.outside, 'space # assets', 'app.js'), 'EXTERNAL_ASSET') - const result = await harness.endpoint().readRelated(agent, base, './app.js', signal()) + const result = await harness.endpoint().readRelated(harness.scope, base, './app.js', signal()) expect(Buffer.from(result.data, 'base64').toString()).toBe('EXTERNAL_ASSET') }) @@ -100,14 +100,14 @@ describe('workspaceFiles.readRelated', () => { vi.spyOn(harness.ctx.fs, 'processPath').mockReturnValue(base) const read = vi.spyOn(files, 'readAll').mockResolvedValue({ absolutePath: expected, version: 'v', offset: 0, data: '', eof: true }) const caller = signal() - await files.readRelated(agent, 'base', './app.js', caller) - expect(read).toHaveBeenCalledWith(agent, expected, caller) + await files.readRelated(harness.scope, 'base', './app.js', caller) + expect(read).toHaveBeenCalledWith(harness.scope, expected, caller) }) it('rejects a related symlink rather than following it', async () => { await writeFile(join(harness.workspace, 'base'), 'base') await writeFile(join(harness.workspace, 'target'), 'target') await symlink('target', join(harness.workspace, 'link')) - expect((await failureOf(harness.endpoint().readRelated(agent, 'base', 'link', signal()))).code).toBe('workspace-file/not-regular-file') + expect((await failureOf(harness.endpoint().readRelated(harness.scope, 'base', 'link', signal()))).code).toBe('workspace-file/not-regular-file') }) }) diff --git a/packages/api/workspace-files/tests/read-bytes.spec.ts b/packages/api/workspace-files/tests/read-bytes.spec.ts index 73f64f71e4..2af2ca9927 100644 --- a/packages/api/workspace-files/tests/read-bytes.spec.ts +++ b/packages/api/workspace-files/tests/read-bytes.spec.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdir, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { FsVersion } from '@deepseek-ai/dsh-fs' -import { agent, failureOf, openWorkspace, signal, type Harness } from './harness.ts' +import { failureOf, openWorkspace, signal, type Harness } from './harness.ts' let harness: Harness let workspace: string @@ -27,7 +27,7 @@ const decode = (data: string): Buffer => Buffer.from(data, 'base64') describe('workspaceFiles.readBytes — the window', () => { it('returns the whole file as one window by default, with its absolute path, version, and size', async () => { await writeFile(join(workspace, 'ramp.bin'), RAMP) - const result = await endpoint().readBytes(agent, 'ramp.bin', {}, signal()) + const result = await endpoint().readBytes(harness.scope, 'ramp.bin', {}, signal()) expect(decode(result.data).equals(RAMP)).toBe(true) expect(result).toMatchObject({ offset: 0, eof: true, bytes: 256 }) expect(result.absolutePath.endsWith('ramp.bin')).toBe(true) @@ -36,14 +36,14 @@ describe('workspaceFiles.readBytes — the window', () => { it('cuts the requested window and reports that more follows', async () => { await writeFile(join(workspace, 'ramp.bin'), RAMP) - const result = await endpoint().readBytes(agent, 'ramp.bin', { offset: 16, length: 8 }, signal()) + const result = await endpoint().readBytes(harness.scope, 'ramp.bin', { offset: 16, length: 8 }, signal()) expect([...decode(result.data)]).toEqual([16, 17, 18, 19, 20, 21, 22, 23]) expect(result).toMatchObject({ offset: 16, eof: false, bytes: 256 }) }) it('reads a window of a file far above the byte cap', async () => { await writeFile(join(workspace, 'huge.bin'), Buffer.alloc(200_000, 7)) - const result = await endpoint({ maxBytes: 1024 }).readBytes(agent, 'huge.bin', { offset: 199_000, length: 1024 }, signal()) + const result = await endpoint({ maxBytes: 1024 }).readBytes(harness.scope, 'huge.bin', { offset: 199_000, length: 1024 }, signal()) expect(decode(result.data)).toHaveLength(1000) expect(result).toMatchObject({ eof: true, bytes: 200_000 }) }) @@ -51,46 +51,46 @@ describe('workspaceFiles.readBytes — the window', () => { it('infers eof from a short window when the backend reports no size', async () => { await writeFile(join(workspace, 'ramp.bin'), RAMP) vi.spyOn(harness.ctx.fs, 'stat').mockResolvedValue({ version: FsVersion('v-sizeless'), type: 'file' }) - const full = await endpoint().readBytes(agent, 'ramp.bin', { offset: 0, length: 256 }, signal()) + const full = await endpoint().readBytes(harness.scope, 'ramp.bin', { offset: 0, length: 256 }, signal()) expect(full.eof).toBe(false) - const short = await endpoint().readBytes(agent, 'ramp.bin', { offset: 250, length: 10 }, signal()) + const short = await endpoint().readBytes(harness.scope, 'ramp.bin', { offset: 250, length: 10 }, signal()) expect(short).toMatchObject({ eof: true }) expect(short.bytes).toBeUndefined() }) it('reports eof on the window that holds the last byte, whether or not the length is reached', async () => { await writeFile(join(workspace, 'ramp.bin'), RAMP) - const exact = await endpoint().readBytes(agent, 'ramp.bin', { offset: 248, length: 8 }, signal()) + const exact = await endpoint().readBytes(harness.scope, 'ramp.bin', { offset: 248, length: 8 }, signal()) expect(exact.eof).toBe(true) expect(decode(exact.data)).toHaveLength(8) - const short = await endpoint().readBytes(agent, 'ramp.bin', { offset: 250, length: 100 }, signal()) + const short = await endpoint().readBytes(harness.scope, 'ramp.bin', { offset: 250, length: 100 }, signal()) expect(short.eof).toBe(true) expect([...decode(short.data)]).toEqual([250, 251, 252, 253, 254, 255]) }) it('returns an empty eof window for an offset at or past the end', async () => { await writeFile(join(workspace, 'ramp.bin'), RAMP) - const result = await endpoint().readBytes(agent, 'ramp.bin', { offset: 300, length: 8 }, signal()) + const result = await endpoint().readBytes(harness.scope, 'ramp.bin', { offset: 300, length: 8 }, signal()) expect(result).toMatchObject({ data: '', offset: 300, eof: true, bytes: 256 }) }) it('returns an empty eof window for an empty file', async () => { await writeFile(join(workspace, 'empty.bin'), Buffer.alloc(0)) - const result = await endpoint().readBytes(agent, 'empty.bin', {}, signal()) + const result = await endpoint().readBytes(harness.scope, 'empty.bin', {}, signal()) expect(result).toMatchObject({ data: '', offset: 0, eof: true, bytes: 0 }) }) it('carries bytes a text read would refuse: NUL and invalid UTF-8 round-trip through base64', async () => { const raw = Buffer.from([0, 0xff, 0xfe, 0x80, 0x41, 0]) await writeFile(join(workspace, 'blob.bin'), raw) - const result = await endpoint().readBytes(agent, 'blob.bin', {}, signal()) + const result = await endpoint().readBytes(harness.scope, 'blob.bin', {}, signal()) expect(decode(result.data).equals(raw)).toBe(true) }) it('names the version a stat of the same file reports', async () => { await writeFile(join(workspace, 'ramp.bin'), RAMP) - const stat = await endpoint().stat(agent, 'ramp.bin', signal()) - const result = await endpoint().readBytes(agent, 'ramp.bin', {}, signal()) + const stat = await endpoint().stat(harness.scope, 'ramp.bin', signal()) + const result = await endpoint().readBytes(harness.scope, 'ramp.bin', {}, signal()) expect(result.version).toBe(stat.version) }) }) @@ -98,21 +98,21 @@ describe('workspaceFiles.readBytes — the window', () => { describe('workspaceFiles.readBytes — defaults and cap', () => { it('defaults the length to the configured byte cap', async () => { await writeFile(join(workspace, 'ramp.bin'), RAMP.subarray(0, 64)) - const result = await endpoint({ maxBytes: 64 }).readBytes(agent, 'ramp.bin', {}, signal()) + const result = await endpoint({ maxBytes: 64 }).readBytes(harness.scope, 'ramp.bin', {}, signal()) expect(decode(result.data)).toHaveLength(64) expect(result.eof).toBe(true) }) it('refuses a window longer than the cap as too-large rather than shortening it', async () => { await writeFile(join(workspace, 'ramp.bin'), RAMP) - const failure = await failureOf(endpoint({ maxBytes: 64 }).readBytes(agent, 'ramp.bin', { length: 65 }, signal())) + const failure = await failureOf(endpoint({ maxBytes: 64 }).readBytes(harness.scope, 'ramp.bin', { length: 65 }, signal())) expect(failure.code).toBe('workspace-file/too-large') expect(failure.details).toMatchObject({ limit: 64 }) }) it('accepts a window exactly at the cap', async () => { await writeFile(join(workspace, 'ramp.bin'), RAMP.subarray(0, 64)) - const result = await endpoint({ maxBytes: 64 }).readBytes(agent, 'ramp.bin', { length: 64 }, signal()) + const result = await endpoint({ maxBytes: 64 }).readBytes(harness.scope, 'ramp.bin', { length: 64 }, signal()) expect(decode(result.data)).toHaveLength(64) }) @@ -124,7 +124,7 @@ describe('workspaceFiles.readBytes — defaults and cap', () => { { offset: 2 ** 53 }, { offset: Number.MAX_SAFE_INTEGER, length: 2 }, ] for (const range of ranges) { - const failure = await failureOf(endpoint().readBytes(agent, 'ramp.bin', range, signal())) + const failure = await failureOf(endpoint().readBytes(harness.scope, 'ramp.bin', range, signal())) expect(failure.code).toBe('gateway/bad-request') } }) @@ -133,14 +133,14 @@ describe('workspaceFiles.readBytes — defaults and cap', () => { describe('workspaceFiles.readBytes — the gates it shares with read', () => { it('rejects a directory, a missing path, and an empty path', async () => { await mkdir(join(workspace, 'dir')) - expect((await failureOf(endpoint().readBytes(agent, 'dir', {}, signal()))).code).toBe('workspace-file/not-regular-file') - expect((await failureOf(endpoint().readBytes(agent, 'missing.bin', {}, signal()))).code).toBe('workspace-file/not-found') - expect((await failureOf(endpoint().readBytes(agent, '', {}, signal()))).code).toBe('gateway/bad-request') + expect((await failureOf(endpoint().readBytes(harness.scope, 'dir', {}, signal()))).code).toBe('workspace-file/not-regular-file') + expect((await failureOf(endpoint().readBytes(harness.scope, 'missing.bin', {}, signal()))).code).toBe('workspace-file/not-found') + expect((await failureOf(endpoint().readBytes(harness.scope, '', {}, signal()))).code).toBe('gateway/bad-request') }) it('reads a bounded byte window outside the workspace', async () => { await writeFile(join(harness.outside, 'sample.bin'), RAMP) - const result = await endpoint().readBytes(agent, join(harness.outside, 'sample.bin'), { offset: 2, length: 4 }, signal()) + const result = await endpoint().readBytes(harness.scope, join(harness.outside, 'sample.bin'), { offset: 2, length: 4 }, signal()) expect(decode(result.data)).toEqual(RAMP.subarray(2, 6)) expect(result).toMatchObject({ offset: 2, eof: false }) }) diff --git a/packages/api/workspace-files/tests/read.spec.ts b/packages/api/workspace-files/tests/read.spec.ts index 82fced2461..b79f6edebc 100644 --- a/packages/api/workspace-files/tests/read.spec.ts +++ b/packages/api/workspace-files/tests/read.spec.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdir, rm, symlink, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { FsError } from '@deepseek-ai/dsh-fs' -import { agent, failureOf, openWorkspace, signal, type Harness } from './harness.ts' +import { failureOf, openWorkspace, signal, type Harness } from './harness.ts' let harness: Harness let workspace: string @@ -39,7 +39,7 @@ async function lateNul(): Promise { describe('workspaceFiles.read — the happy path', () => { it('returns the whole file as one page with its absolute path, version, and byte size', async () => { await writeFile(join(workspace, 'notes.txt'), 'hello\nworld\n', 'utf8') - const result = await endpoint().read(agent, 'notes.txt', {}, signal()) + const result = await endpoint().read(harness.scope, 'notes.txt', {}, signal()) expect(result.text).toBe('hello\nworld') expect(result.offset).toBe(1) expect(result.lines).toBe(2) @@ -52,19 +52,19 @@ describe('workspaceFiles.read — the happy path', () => { it('reads a nested path relative to the workspace root, not to any backend cwd', async () => { await mkdir(join(workspace, 'src', 'deep'), { recursive: true }) await writeFile(join(workspace, 'src', 'deep', 'a.ts'), 'export {}\n', 'utf8') - const result = await endpoint().read(agent, 'src/deep/a.ts', {}, signal()) + const result = await endpoint().read(harness.scope, 'src/deep/a.ts', {}, signal()) expect(result.text).toBe('export {}') }) it('returns an empty page for an empty file', async () => { await writeFile(join(workspace, 'empty.txt'), '', 'utf8') - const result = await endpoint().read(agent, 'empty.txt', {}, signal()) + const result = await endpoint().read(harness.scope, 'empty.txt', {}, signal()) expect(result).toMatchObject({ text: '', lines: 0, eof: true, bytes: 0 }) }) it('accepts multi-byte UTF-8 and counts the file bytes, not its characters', async () => { await writeFile(join(workspace, 'zh.txt'), '侧栏', 'utf8') - const result = await endpoint().read(agent, 'zh.txt', {}, signal()) + const result = await endpoint().read(harness.scope, 'zh.txt', {}, signal()) expect(result.text).toBe('侧栏') expect(result.bytes).toBe(6) }) @@ -73,16 +73,16 @@ describe('workspaceFiles.read — the happy path', () => { describe('workspaceFiles.read — the line window', () => { it('cuts the requested lines and reports that more follow', async () => { await twentyLines() - const result = await endpoint().read(agent, 'long.txt', { offset: 6, limit: 3 }, signal()) + const result = await endpoint().read(harness.scope, 'long.txt', { offset: 6, limit: 3 }, signal()) expect(result).toMatchObject({ offset: 6, text: 'line 6\nline 7\nline 8', lines: 3, eof: false }) }) it('reports eof on the page that holds the last line, whether or not the limit is reached', async () => { await twentyLines() const service = endpoint() - const exact = await service.read(agent, 'long.txt', { offset: 16, limit: 5 }, signal()) + const exact = await service.read(harness.scope, 'long.txt', { offset: 16, limit: 5 }, signal()) expect(exact).toMatchObject({ text: 'line 16\nline 17\nline 18\nline 19\nline 20', lines: 5, eof: true }) - const beyond = await service.read(agent, 'long.txt', { offset: 19, limit: 10 }, signal()) + const beyond = await service.read(harness.scope, 'long.txt', { offset: 19, limit: 10 }, signal()) expect(beyond).toMatchObject({ text: 'line 19\nline 20', lines: 2, eof: true }) }) @@ -90,21 +90,21 @@ describe('workspaceFiles.read — the line window', () => { await writeFile(join(workspace, 'two.txt'), 'a\nb\n', 'utf8') await writeFile(join(workspace, 'three.txt'), 'a\nb\n\n', 'utf8') const service = endpoint() - expect(await service.read(agent, 'two.txt', { limit: 2 }, signal())).toMatchObject({ text: 'a\nb', lines: 2, eof: true }) - expect(await service.read(agent, 'three.txt', { limit: 2 }, signal())).toMatchObject({ text: 'a\nb', lines: 2, eof: false }) + expect(await service.read(harness.scope, 'two.txt', { limit: 2 }, signal())).toMatchObject({ text: 'a\nb', lines: 2, eof: true }) + expect(await service.read(harness.scope, 'three.txt', { limit: 2 }, signal())).toMatchObject({ text: 'a\nb', lines: 2, eof: false }) // The third line is empty, not absent: `lines` tells it from a page past the end. - expect(await service.read(agent, 'three.txt', { offset: 3 }, signal())).toMatchObject({ text: '', lines: 1, eof: true }) + expect(await service.read(harness.scope, 'three.txt', { offset: 3 }, signal())).toMatchObject({ text: '', lines: 1, eof: true }) }) it('returns an empty eof page for an offset past the last line', async () => { await twentyLines() - const result = await endpoint().read(agent, 'long.txt', { offset: 21 }, signal()) + const result = await endpoint().read(harness.scope, 'long.txt', { offset: 21 }, signal()) expect(result).toMatchObject({ offset: 21, text: '', lines: 0, eof: true }) }) it('defaults the limit to the configured page size', async () => { await twentyLines() - const result = await endpoint({ maxLines: 5 }).read(agent, 'long.txt', {}, signal()) + const result = await endpoint({ maxLines: 5 }).read(harness.scope, 'long.txt', {}, signal()) expect(result.text.split('\n')).toHaveLength(5) expect(result.eof).toBe(false) }) @@ -113,14 +113,14 @@ describe('workspaceFiles.read — the line window', () => { await twentyLines() const service = endpoint({ maxLines: 5 }) for (const range of [{ limit: 6 }, { offset: 0 }, { limit: 1.5 }, { offset: -3 }]) { - const failure = await failureOf(service.read(agent, 'long.txt', range, signal())) + const failure = await failureOf(service.read(harness.scope, 'long.txt', range, signal())) expect(failure.code).toBe('gateway/bad-request') } }) it('keeps carriage returns: the page is the file text, not a rendering of it', async () => { await writeFile(join(workspace, 'crlf.txt'), 'a\r\nb\r\n', 'utf8') - const result = await endpoint().read(agent, 'crlf.txt', {}, signal()) + const result = await endpoint().read(harness.scope, 'crlf.txt', {}, signal()) expect(result.text).toBe('a\r\nb\r') }) }) @@ -130,7 +130,7 @@ describe('workspaceFiles.read — read access and file kinds', () => { await writeFile(join(outside, 'notes.txt'), 'outside\nread only\n', 'utf8') const write = vi.spyOn(harness.ctx.fs, 'writeText') const edit = vi.spyOn(harness.ctx.fs, 'editText') - const result = await endpoint().read(agent, join(outside, 'notes.txt'), {}, signal()) + const result = await endpoint().read(harness.scope, join(outside, 'notes.txt'), {}, signal()) expect(result).toMatchObject({ text: 'outside\nread only', lines: 2, eof: true }) expect(write).not.toHaveBeenCalled() expect(edit).not.toHaveBeenCalled() @@ -138,14 +138,14 @@ describe('workspaceFiles.read — read access and file kinds', () => { it('resolves a relative file outside the workspace on the Host', async () => { await writeFile(join(outside, 'notes.txt'), 'outside', 'utf8') - expect(await endpoint().read(agent, '../outside/notes.txt', {}, signal())).toMatchObject({ text: 'outside', eof: true }) + expect(await endpoint().read(harness.scope, '../outside/notes.txt', {}, signal())).toMatchObject({ text: 'outside', eof: true }) }) it('preserves a filesystem provider refusal for an outside file', async () => { await writeFile(join(outside, 'notes.txt'), 'outside', 'utf8') const refusal = new FsError('backend denied read', 'FS_SANDBOX_DENIED') vi.spyOn(harness.ctx.fs, 'streamText').mockRejectedValue(refusal) - await expect(endpoint().read(agent, join(outside, 'notes.txt'), {}, signal())).rejects.toBe(refusal) + await expect(endpoint().read(harness.scope, join(outside, 'notes.txt'), {}, signal())).rejects.toBe(refusal) }) it('rejects a symlink that points out of the workspace — the case a prefix test cannot see', async () => { @@ -153,7 +153,7 @@ describe('workspaceFiles.read — read access and file kinds', () => { // The path itself is inside the workspace and would pass any string // comparison; only lstat (before the follow) or realpath containment catches it. await symlink(join(outside, 'secret.txt'), join(workspace, 'link.txt')) - const failure = await failureOf(endpoint().read(agent, 'link.txt', {}, signal())) + const failure = await failureOf(endpoint().read(harness.scope, 'link.txt', {}, signal())) expect(failure.code).toBe('workspace-file/not-regular-file') expect(failure.details).toMatchObject({ kind: 'symlink' }) }) @@ -161,24 +161,24 @@ describe('workspaceFiles.read — read access and file kinds', () => { it('rejects a symlink even when it points back inside the workspace', async () => { await writeFile(join(workspace, 'real.txt'), 'fine', 'utf8') await symlink(join(workspace, 'real.txt'), join(workspace, 'alias.txt')) - const failure = await failureOf(endpoint().read(agent, 'alias.txt', {}, signal())) + const failure = await failureOf(endpoint().read(harness.scope, 'alias.txt', {}, signal())) expect(failure.code).toBe('workspace-file/not-regular-file') }) it('rejects a directory, which has no text to return', async () => { await mkdir(join(workspace, 'src'), { recursive: true }) - const failure = await failureOf(endpoint().read(agent, 'src', {}, signal())) + const failure = await failureOf(endpoint().read(harness.scope, 'src', {}, signal())) expect(failure.code).toBe('workspace-file/not-regular-file') expect(failure.details).toMatchObject({ kind: 'directory' }) }) it('reports a missing path as not found', async () => { - const failure = await failureOf(endpoint().read(agent, 'nope.txt', {}, signal())) + const failure = await failureOf(endpoint().read(harness.scope, 'nope.txt', {}, signal())) expect(failure.code).toBe('workspace-file/not-found') }) it('refuses an empty path as a bad request', async () => { - const failure = await failureOf(endpoint().read(agent, '', {}, signal())) + const failure = await failureOf(endpoint().read(harness.scope, '', {}, signal())) expect(failure.code).toBe('gateway/bad-request') }) }) @@ -186,26 +186,26 @@ describe('workspaceFiles.read — read access and file kinds', () => { describe('workspaceFiles.read — gate 3: the page byte cap', () => { it('fails a page above the cap rather than returning it shortened', async () => { await writeFile(join(workspace, 'big.txt'), 'x'.repeat(4096), 'utf8') - const failure = await failureOf(endpoint({ maxBytes: 1024 }).read(agent, 'big.txt', {}, signal())) + const failure = await failureOf(endpoint({ maxBytes: 1024 }).read(harness.scope, 'big.txt', {}, signal())) expect(failure.code).toBe('workspace-file/too-large') expect(failure.details).toMatchObject({ limit: 1024 }) }) it('accepts a page exactly at the cap, because the cap is inclusive', async () => { await writeFile(join(workspace, 'exact.txt'), `${'x'.repeat(31)}\n${'y'.repeat(32)}\n`, 'utf8') - const result = await endpoint({ maxBytes: 64 }).read(agent, 'exact.txt', {}, signal()) + const result = await endpoint({ maxBytes: 64 }).read(harness.scope, 'exact.txt', {}, signal()) expect(result.text).toHaveLength(64) }) it('counts the newlines between the page lines against the cap', async () => { await writeFile(join(workspace, 'exact.txt'), `${'x'.repeat(31)}\n${'y'.repeat(32)}\n`, 'utf8') - const failure = await failureOf(endpoint({ maxBytes: 63 }).read(agent, 'exact.txt', {}, signal())) + const failure = await failureOf(endpoint({ maxBytes: 63 }).read(harness.scope, 'exact.txt', {}, signal())) expect(failure.code).toBe('workspace-file/too-large') }) it('caps the page, not the file: a small window of a file far above the cap reads', async () => { await writeFile(join(workspace, 'huge.txt'), Array.from({ length: 2000 }, (_, i) => `row ${i} ${'z'.repeat(100)}`).join('\n'), 'utf8') - const result = await endpoint({ maxBytes: 1024 }).read(agent, 'huge.txt', { offset: 1990, limit: 3 }, signal()) + const result = await endpoint({ maxBytes: 1024 }).read(harness.scope, 'huge.txt', { offset: 1990, limit: 3 }, signal()) expect(result.text.split('\n')).toHaveLength(3) expect(result.eof).toBe(false) expect(result.bytes).toBeGreaterThan(200_000) @@ -215,22 +215,22 @@ describe('workspaceFiles.read — gate 3: the page byte cap', () => { describe('workspaceFiles.read — gate 4: text only', () => { it('rejects bytes that are not valid UTF-8', async () => { await writeFile(join(workspace, 'bin.dat'), Buffer.from([0xff, 0xfe, 0xfd])) - const failure = await failureOf(endpoint().read(agent, 'bin.dat', {}, signal())) + const failure = await failureOf(endpoint().read(harness.scope, 'bin.dat', {}, signal())) expect(failure.code).toBe('workspace-file/not-text') }) it('rejects a page that carries NUL bytes, wherever in the file the page lies', async () => { await writeFile(join(workspace, 'nul.dat'), Buffer.from([0x61, 0x00, 0x62])) const service = endpoint() - expect((await failureOf(service.read(agent, 'nul.dat', {}, signal()))).code).toBe('workspace-file/not-text') + expect((await failureOf(service.read(harness.scope, 'nul.dat', {}, signal()))).code).toBe('workspace-file/not-text') // Past the backend's own binary sample, so only the page scan can see it. await lateNul() - expect((await failureOf(service.read(agent, 'late-nul.txt', { offset: 2 }, signal()))).code).toBe('workspace-file/not-text') + expect((await failureOf(service.read(harness.scope, 'late-nul.txt', { offset: 2 }, signal()))).code).toBe('workspace-file/not-text') }) it('reads a page that ends before a NUL byte, because detection is per page', async () => { await lateNul() - const result = await endpoint().read(agent, 'late-nul.txt', { limit: 1 }, signal()) + const result = await endpoint().read(harness.scope, 'late-nul.txt', { limit: 1 }, signal()) expect(result.text).toHaveLength(9000) expect(result.eof).toBe(false) }) @@ -251,7 +251,7 @@ describe('workspaceFiles.read — the file changing under its gate', () => { it('reports a file deleted after the gate as not found, not as an internal failure', async () => { await writeFile(join(workspace, 'fleeting.txt'), 'x', 'utf8') afterGate(() => rm(join(workspace, 'fleeting.txt'))) - const failure = await failureOf(endpoint().read(agent, 'fleeting.txt', {}, signal())) + const failure = await failureOf(endpoint().read(harness.scope, 'fleeting.txt', {}, signal())) expect(failure.code).toBe('workspace-file/not-found') }) @@ -261,7 +261,7 @@ describe('workspaceFiles.read — the file changing under its gate', () => { await rm(join(workspace, 'fleeting.txt')) await mkdir(join(workspace, 'fleeting.txt')) }) - const failure = await failureOf(endpoint().read(agent, 'fleeting.txt', {}, signal())) + const failure = await failureOf(endpoint().read(harness.scope, 'fleeting.txt', {}, signal())) expect(failure.code).toBe('workspace-file/not-regular-file') expect(failure.details).toMatchObject({ kind: 'directory' }) }) @@ -269,6 +269,6 @@ describe('workspaceFiles.read — the file changing under its gate', () => { it('passes any other backend failure through unchanged', async () => { await writeFile(join(workspace, 'notes.txt'), 'x', 'utf8') vi.spyOn(harness.ctx.fs, 'streamText').mockRejectedValue(new FsError('disk unreadable', 'FS_IO_ERROR')) - await expect(endpoint().read(agent, 'notes.txt', {}, signal())).rejects.toMatchObject({ code: 'FS_IO_ERROR' }) + await expect(endpoint().read(harness.scope, 'notes.txt', {}, signal())).rejects.toMatchObject({ code: 'FS_IO_ERROR' }) }) }) diff --git a/packages/api/workspace-files/tests/scope.spec.ts b/packages/api/workspace-files/tests/scope.spec.ts new file mode 100644 index 0000000000..2d9cd097b2 --- /dev/null +++ b/packages/api/workspace-files/tests/scope.spec.ts @@ -0,0 +1,75 @@ +import { resolve } from 'node:path' +import { Context } from '@deepseek-ai/cordis' +import SessionStore, { SESSION_FORMAT_VERSION, SessionId, type SessionHeader } from '@deepseek-ai/dsh-session' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' +import { describe, expect, it, vi } from 'vitest' +import WorkspaceFiles from '../src/index.ts' + +const CAPS = { + maxBytes: 1024, + maxFileBytes: 1024, + maxLines: 100, + maxEntries: 100, +} + +function header(id: SessionId, cwd?: string): SessionHeader { + return { + version: SESSION_FORMAT_VERSION, + id, + createdAt: 1, + isSeeded: false, + origin: 'subagent', + ...cwd === undefined ? {} : { cwd }, + } +} + +describe('Workspace Files Session scope lookup', () => { + it('uses live or stored headers without an Agent and leaves with its plugin', async () => { + const liveId = SessionId('live-subagent') + const coldId = SessionId('cold-subagent') + const fallbackId = SessionId('cold-without-cwd') + const missingId = SessionId('missing') + const liveRoot = resolve('live-workspace') + const coldRoot = resolve('cold-workspace') + const fallbackRoot = resolve('fallback-workspace') + const stat = vi.fn(async (id: SessionId) => { + if (id === coldId) return { header: header(coldId, coldRoot) } + if (id === fallbackId) return { header: header(fallbackId) } + return undefined + }) + const ctx = new Context() + ctx.provide('fs', {} as never) + ctx.provide('sandboxPolicy', { workspaceRoot: fallbackRoot } as never) + ctx.provide('sessionPersistence', { stat } as never) + const sessions = await ctx.plugin(SessionStore) + const typert = await ctx.plugin(TypertRegistry) + const workspaceFiles = await ctx.plugin(WorkspaceFiles, CAPS) + + try { + ctx.sessions.create(liveId, { meta: { cwd: liveRoot, origin: 'subagent' } }) + expect(ctx.get('agents')).toBeUndefined() + const lookup = ctx.typert.lookups.get('workspaceFileScope') + expect(lookup).toMatchObject({ + parameter: 'workspaceFileScope', + wire: 'workspaceFileScopeId', + hostTypeSymbol: '@deepseek-ai/dsh-api-workspace-files#WorkspaceFileScope', + wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + }) + if (lookup === undefined) throw new Error('workspaceFileScope lookup did not register') + + await expect(lookup.resolve(liveId)).resolves.toEqual({ sessionId: liveId, workspaceRoot: liveRoot }) + expect(stat).not.toHaveBeenCalled() + await expect(lookup.resolve(coldId)).resolves.toEqual({ sessionId: coldId, workspaceRoot: coldRoot }) + await expect(lookup.resolve(fallbackId)).resolves.toEqual({ sessionId: fallbackId, workspaceRoot: fallbackRoot }) + await expect(lookup.resolve(missingId)).resolves.toBeUndefined() + expect(stat.mock.calls.map(([id]) => id)).toEqual([coldId, fallbackId, missingId]) + + await workspaceFiles.dispose() + expect(ctx.typert.lookups.get('workspaceFileScope')).toBeUndefined() + } finally { + await workspaceFiles.dispose() + await sessions.dispose() + await typert.dispose() + } + }) +}) diff --git a/packages/api/workspace-files/tests/stat.spec.ts b/packages/api/workspace-files/tests/stat.spec.ts index 5b22d5cb0d..b21f658b62 100644 --- a/packages/api/workspace-files/tests/stat.spec.ts +++ b/packages/api/workspace-files/tests/stat.spec.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdir, symlink, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { FsVersion } from '@deepseek-ai/dsh-fs' -import { agent, failureOf, openWorkspace, signal, type Harness } from './harness.ts' +import { failureOf, openWorkspace, signal, type Harness } from './harness.ts' let harness: Harness @@ -18,7 +18,7 @@ afterEach(async () => { describe('workspaceFiles.stat', () => { it('returns the absolute path, a version, and the byte size', async () => { await writeFile(join(harness.workspace, 'notes.txt'), 'hello\n', 'utf8') - const result = await harness.endpoint().stat(agent, 'notes.txt', signal()) + const result = await harness.endpoint().stat(harness.scope, 'notes.txt', signal()) expect(result.absolutePath).toBe(harness.ctx.fs.processPath(await harness.ctx.fs.resolve(join(harness.workspace, 'notes.txt')))) expect(result.version.length).toBeGreaterThan(0) expect(result.bytes).toBe(6) @@ -28,11 +28,11 @@ describe('workspaceFiles.stat', () => { const path = join(harness.workspace, 'notes.txt') await writeFile(path, 'one\n', 'utf8') const endpoint = harness.endpoint() - const before = await endpoint.stat(agent, 'notes.txt', signal()) - const page = await endpoint.read(agent, 'notes.txt', {}, signal()) + const before = await endpoint.stat(harness.scope, 'notes.txt', signal()) + const page = await endpoint.read(harness.scope, 'notes.txt', {}, signal()) expect(page.version).toBe(before.version) await writeFile(path, 'one\ntwo\n', 'utf8') - const after = await endpoint.stat(agent, 'notes.txt', signal()) + const after = await endpoint.stat(harness.scope, 'notes.txt', signal()) expect(after.version).not.toBe(before.version) expect(after.bytes).toBe(8) }) @@ -40,7 +40,7 @@ describe('workspaceFiles.stat', () => { it('rejects under a signal the caller already aborted, before any path resolves', async () => { const controller = new AbortController() controller.abort() - await expect(harness.endpoint().stat(agent, 'notes.txt', controller.signal)).rejects.toThrow() + await expect(harness.endpoint().stat(harness.scope, 'notes.txt', controller.signal)).rejects.toThrow() }) it('resolves the workspace root and then the file under the caller\'s signal', async () => { @@ -49,7 +49,7 @@ describe('workspaceFiles.stat', () => { const original = fs.resolve.bind(fs) const spy = vi.spyOn(fs, 'resolve').mockImplementation((path, opts) => original(path, opts)) const controller = new AbortController() - await harness.endpoint().stat(agent, 'notes.txt', controller.signal) + await harness.endpoint().stat(harness.scope, 'notes.txt', controller.signal) expect(spy.mock.calls.map(([, opts]) => opts?.signal)).toEqual([controller.signal, controller.signal]) spy.mockRestore() }) @@ -57,7 +57,7 @@ describe('workspaceFiles.stat', () => { it('omits bytes when the backend reports no size', async () => { await writeFile(join(harness.workspace, 'notes.txt'), 'hello\n', 'utf8') vi.spyOn(harness.ctx.fs, 'stat').mockResolvedValue({ version: FsVersion('v-sizeless'), type: 'file' }) - const result = await harness.endpoint().stat(agent, 'notes.txt', signal()) + const result = await harness.endpoint().stat(harness.scope, 'notes.txt', signal()) expect(result).toEqual({ absolutePath: result.absolutePath, version: 'v-sizeless' }) }) @@ -66,13 +66,13 @@ describe('workspaceFiles.stat', () => { await symlink(join(harness.outside, 'secret.txt'), join(harness.workspace, 'link.txt')) await mkdir(join(harness.workspace, 'src')) const endpoint = harness.endpoint() - expect(await failureOf(endpoint.stat(agent, 'link.txt', signal()))).toMatchObject({ + expect(await failureOf(endpoint.stat(harness.scope, 'link.txt', signal()))).toMatchObject({ code: 'workspace-file/not-regular-file', details: { kind: 'symlink' }, }) - expect((await failureOf(endpoint.stat(agent, 'src', signal()))).details).toMatchObject({ kind: 'directory' }) - expect(await endpoint.stat(agent, join(harness.outside, 'secret.txt'), signal())).toMatchObject({ bytes: 2 }) - expect((await failureOf(endpoint.stat(agent, 'nope.txt', signal()))).code).toBe('workspace-file/not-found') - expect((await failureOf(endpoint.stat(agent, '', signal()))).code).toBe('gateway/bad-request') + expect((await failureOf(endpoint.stat(harness.scope, 'src', signal()))).details).toMatchObject({ kind: 'directory' }) + expect(await endpoint.stat(harness.scope, join(harness.outside, 'secret.txt'), signal())).toMatchObject({ bytes: 2 }) + expect((await failureOf(endpoint.stat(harness.scope, 'nope.txt', signal()))).code).toBe('workspace-file/not-found') + expect((await failureOf(endpoint.stat(harness.scope, '', signal()))).code).toBe('gateway/bad-request') }) }) diff --git a/packages/api/workspace-files/tsconfig.host.json b/packages/api/workspace-files/tsconfig.host.json index c26a622e87..3bf11f5177 100644 --- a/packages/api/workspace-files/tsconfig.host.json +++ b/packages/api/workspace-files/tsconfig.host.json @@ -17,9 +17,6 @@ { "path": "../../../vendor/schemastery" }, - { - "path": "../../core/agent" - }, { "path": "../../core/session" }, @@ -29,6 +26,9 @@ { "path": "../../sandbox/sandbox-policy" }, + { + "path": "../../session/session-persistence" + }, { "path": "../../typert/protocol" }, diff --git a/packages/attachment/attachment-local/package.json b/packages/attachment/attachment-local/package.json index 71034eb684..afa52d914f 100644 --- a/packages/attachment/attachment-local/package.json +++ b/packages/attachment/attachment-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-attachment-local", "description": "Private content-addressed DSH_HOME attachment storage", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/attachment/attachment/package.json b/packages/attachment/attachment/package.json index 48cde83037..d98db07b61 100644 --- a/packages/attachment/attachment/package.json +++ b/packages/attachment/attachment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-attachment", "description": "Durable immutable attachment storage seam for the DeepSeek Harness", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/boot/app-boot/package.json b/packages/boot/app-boot/package.json index 2e49da08de..64eedc7ae1 100644 --- a/packages/boot/app-boot/package.json +++ b/packages/boot/app-boot/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-app-boot", "description": "Shared boot glue for the app bins: .env loading, fail-loud Loader guards, snapshot-aware config resolution, and the Loader boot sequence", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/boot/cmdline/package.json b/packages/boot/cmdline/package.json index 6a4a9679d9..ea10cc6960 100644 --- a/packages/boot/cmdline/package.json +++ b/packages/boot/cmdline/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-cmdline", "description": "Immutable command-line handoff from a dsh launcher to any app plugin that injects cmdlineArgs", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/acp-app/package.json b/packages/bundle/acp-app/package.json index 3fb7dfaa3b..085b7341e5 100644 --- a/packages/bundle/acp-app/package.json +++ b/packages/bundle/acp-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-acp-app", "description": "The dsh ACP profile bundle: automation-only JSON-RPC stdio and process lifecycle over dsh-base", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 4f3407903a..64e33f55b1 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-base", "description": "The shared dsh core as a profile bundle: the first patch layer of base-backed profiles, inserting core rows over the empty profile root", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index b8f809ebf3..1b9e611457 100644 --- a/packages/bundle/headless/package.json +++ b/packages/bundle/headless/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-headless", "description": "The dsh one-shot bundle: a direct core Agent/Session runner over dsh-base with no Host, HTTP, or browser layer", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/sdk-app/package.json b/packages/bundle/sdk-app/package.json index 1f39ae000b..4fc726caa8 100644 --- a/packages/bundle/sdk-app/package.json +++ b/packages/bundle/sdk-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-app", "description": "The dsh SDK profile bundle: stdio JSON-RPC serving and process lifecycle over dsh-base", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/sdk-minimal/package.json b/packages/bundle/sdk-minimal/package.json index d95375faed..630f448daf 100644 --- a/packages/bundle/sdk-minimal/package.json +++ b/packages/bundle/sdk-minimal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-minimal", "description": "The standalone minimal SDK profile bundle: JSON-RPC, one DeepSeek adapter, persistent shell, and JSONL sessions", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, 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/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 692a6be952..6782a6e444 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-app", "description": "The dsh browser-surface bundle: the web patch layer over dsh-base plus the runtime glue plugin (frontend dist serving, web-surface prompt, bash runtime variables, URL line)", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index d6d400b65c..bf08ff9144 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: 9d015d914b67ad677d6662c8d6c01ae95cb9dcbe +README.md: cf4b6aceb320d942d695fb1dce53c2e67e53a969 +README.zh.md: 5805ad129fc5d1f241fb6594adc7a5f00ca3d0e8 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 9d015d914b..5805ad129f 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) | 在已安装应用中打开工作区目录的会话标题栏拆分按钮 | — | diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index 9c999a025b..16dceff71f 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-connection", "description": "Authenticated RPC transport, generation lifecycle, and browser fixture", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/file-upload/package.json b/packages/client/file-upload/package.json index 015a125351..fce7fd086d 100644 --- a/packages/client/file-upload/package.json +++ b/packages/client/file-upload/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-file-upload", "description": "Agent-scoped browser file upload, streaming intake, and staged receipt service", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/hmr/package.json b/packages/client/hmr/package.json index 6295fa479f..a3df146d0f 100644 --- a/packages/client/hmr/package.json +++ b/packages/client/hmr/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-hmr", "description": "Dev-only hot-reload driver for script-loaded client entries: SSE rebuilt frames → invalidate/prefetch → fiber swap through the vendored Loader entry", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index 10177f91e2..49928c9321 100644 --- a/packages/client/locale/package.json +++ b/packages/client/locale/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-locale", "description": "Locale plugin: Host-backed preference, extensible language catalog, browser fallback, and typed built-in dictionaries", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/modules/package.json b/packages/client/modules/package.json index 1c09baf8a6..fadb06d13e 100644 --- a/packages/client/modules/package.json +++ b/packages/client/modules/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-modules", "description": "Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dsh.client scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/resources/package.json b/packages/client/resources/package.json index cdcd4eed2d..21a47b7544 100644 --- a/packages/client/resources/package.json +++ b/packages/client/resources/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-resources", "description": "Unified client resource model: protocol-registered providers turn URL addresses into live values, consumed through the useResource global standard hook", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/store/package.json b/packages/client/store/package.json index 6bb0c0a212..fe71ce96ce 100644 --- a/packages/client/store/package.json +++ b/packages/client/store/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-store", "description": "React-free observable and snapshot-store contracts with the shared Zustand/Immer engine", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index 9984d47613..857bc9c906 100644 --- a/packages/client/ui-agent-preset/package.json +++ b/packages/client/ui-agent-preset/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-agent-preset", "description": "Agent-preset surfaces: the default for later sessions, this session's seat, and the composition editor", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-approval/package.json b/packages/client/ui-approval/package.json index b4cecaa31d..00138c179e 100644 --- a/packages/client/ui-approval/package.json +++ b/packages/client/ui-approval/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-approval", "description": "Approval composer takeover over the scoped Remote Event waterfall", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json index 9adf41af13..429f1955b3 100644 --- a/packages/client/ui-attachment/package.json +++ b/packages/client/ui-attachment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-attachment", "description": "Dynamic attachment presentation plugin for conversation input, message-image, and trajectory image slots", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-attachment/src/FileCard.tsx b/packages/client/ui-attachment/src/FileCard.tsx index e10c91993c..3692302325 100644 --- a/packages/client/ui-attachment/src/FileCard.tsx +++ b/packages/client/ui-attachment/src/FileCard.tsx @@ -1,4 +1,4 @@ -import { DocumentFileIcon, fileSizeText, IconCloseFill14 } from '@deepseek-ai/dsh-client-ui-primitives' +import { fileExtension, FileTypeIcon, fileSizeText, IconCloseFill14 } from '@deepseek-ai/dsh-client-ui-primitives' import css from './FileCard.module.css' /** Localized strings consumed by one pending-file card. */ @@ -18,13 +18,7 @@ export interface FileCardLabels { /** Upload display state resolved by the owner. */ export type FileCardState = 'uploading' | 'ready' | 'error' -function extensionOf(name: string): string { - const dot = name.lastIndexOf('.') - if (dot <= 0 || dot === name.length - 1) return '' - return name.slice(dot + 1).toUpperCase().slice(0, 8) -} - -/** One pending generic-file card: name, size or upload status, remove, retry. */ +/** One pending file card: type glyph, name, size or upload status, remove, retry. */ export function FileCard({ name, bytes, state, progress, labels, onRemove, onRetry, }: { @@ -36,7 +30,7 @@ export function FileCard({ onRemove: () => void onRetry: () => void }) { - const extension = extensionOf(name) + const extension = fileExtension(name).toUpperCase().slice(0, 8) const meta = state === 'uploading' ? labels.uploading : state === 'error' @@ -51,7 +45,7 @@ export function FileCard({ {state === 'uploading' ? - : } + : } {retryable ? ( diff --git a/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx b/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx index 8e6f01474e..c0d0920c52 100644 --- a/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx +++ b/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx @@ -263,4 +263,17 @@ describe('ComposerAttachments file drafts', () => { expect(group.textContent).toContain('文件') expect(group.textContent).toContain('3B') }) + + it('uses the shared leading-dot suffix in ready-file metadata', () => { + const view = render() + expect(view.getByTitle('.env').textContent).toContain('ENV 3B') + }) }) diff --git a/packages/client/ui-brand-official/package.json b/packages/client/ui-brand-official/package.json index 0db54ad4f0..62ddf9c91e 100644 --- a/packages/client/ui-brand-official/package.json +++ b/packages/client/ui-brand-official/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-brand-official", "description": "Official DeepSeek Harness brand occupants for the Web client's sidebar slots", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 037bd023ca..d10cd7b6bb 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md -README.md: 7c9b38a1abc6289c9c0f7b8335d2c4a796e32840 -README.zh.md: cc5cf4a455a355aa3d891cc39307314e9db03e5b +README.md: 860dcb9eeb9c92a14d9128d9d8c95c29796726f8 +README.zh.md: 699d71588afdadb8f165568e41f5e7ffd24df986 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 7c9b38a1ab..860dcb9eeb 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -39,7 +39,7 @@ A completed Turn shows an expandable usage row only when the loaded window inclu ## Turn Process Folding -Settings → General exposes a persisted `Normal` / `Compact` conversation-display preference in the `ui-chat` namespace; `Compact` is the default. Normal leaves process rows visible and renders no Turn-process control. In Compact mode, the System prompt remains independently visible before the opening User throughout the Turn. Context injection, reasoning, Assistant material, Tool rows, and Retry rows remain expanded while a Turn is open. At `turn/end`, its latest Step becomes the final-answer boundary only when it contains non-blank text, an image, or an unknown visible block—and no Tool-call block; preceding Context injection, reasoning, earlier Assistant material, Tool rows, and Retry rows then collapse by default. The control reports Turn-wide durable counts for non-subagent Tool calls, reply-bearing Assistant messages before the final answer, and subagent delegation calls; zero-valued segments are omitted, the Tool and subagent figures are mutually exclusive, and neither System prompt nor Context injection contributes a count. When all three counts are zero, the process still folds and the control reads `Thought for a while`. A full-width divider below the summary separates it from the answer or expanded process rows. User and steering messages, System prompt, error, max-token, and turn-tail rows stay outside, and a closed Turn with no final answer keeps all process evidence visible. A newly available process control is inserted without changing the relative order of existing rows: opening human input precedes the control and process rows from their first projection, while System prompt remains above that input. While older history remains available through Load earlier, process controls stay absent and no members are hidden; once history is complete, every eligible closed Turn uses the collapsed default immediately. Stable Chat Node Seats keep every renderer mounted, hidden members add no flow spacing, and a closed control sits 8px above its answer only when no independent input intervenes. Completion collapse does not depend on tail-follow position, so a reader above the tail may see the transcript reflow. An automatic collapse that would hide keyboard focus keeps the group open and leaves focus in place; a manual close focuses the process control before hiding its members. The session-scoped store records only manually expanded Turn-and-answer-Step generations; a different answer generation starts collapsed. +Settings → General exposes a persisted, localized `Normal` / `Compact` conversation-display preference in the `ui-chat` namespace; `Compact` is the default. Normal leaves process rows visible and renders no Turn-process control. In Compact mode, the System prompt remains independently visible before the opening User throughout the Turn. Context injection, reasoning, Assistant material, Tool rows, and Retry rows remain expanded while a Turn is open. At `turn/end`, its latest Step becomes the final-answer boundary only when it contains non-blank text, an image, or an unknown visible block—and no Tool-call block; preceding Context injection, reasoning, earlier Assistant material, Tool rows, and Retry rows then collapse by default. The control reports Turn-wide durable counts for non-subagent Tool calls, reply-bearing Assistant messages before the final answer, and subagent delegation calls; zero-valued segments are omitted, the Tool and subagent figures are mutually exclusive, and neither System prompt nor Context injection contributes a count. When all three counts are zero, the process still folds and the control reads `Thought for a while`. A full-width divider below the summary separates it from the answer or expanded process rows. User and steering messages, System prompt, error, max-token, and turn-tail rows stay outside, and a closed Turn with no final answer keeps all process evidence visible. A newly available process control is inserted without changing the relative order of existing rows: opening human input precedes the control and process rows from their first projection, while System prompt remains above that input. While older history remains available through Load earlier, process controls stay absent and no members are hidden; once history is complete, every eligible closed Turn uses the collapsed default immediately. Stable Chat Node Seats keep every renderer mounted, hidden members add no flow spacing, and a closed control sits 8px above its answer only when no independent input intervenes. Completion collapse does not depend on tail-follow position, so a reader above the tail may see the transcript reflow. An automatic collapse that would hide keyboard focus keeps the group open and leaves focus in place; a manual close focuses the process control before hiding its members. The session-scoped store records only manually expanded Turn-and-answer-Step generations; a different answer generation starts collapsed. ----- diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index cc5cf4a455..699d71588a 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -8,9 +8,9 @@ kind: "package-reference" ## 概述 -使用本包可在浏览器中渲染已记录的会话对话,包括历史图片、本地化操作和滚动位置恢复。紧凑显示会收起已完成轮次的过程行,同时保持最终答案和独立有用的上下文可见;已打包的历史 Assistant 连续消息保持收起。本地 transcript(文本记录)与 steering(中途引导)提交会立即显示并保留在原区域,在权威会话记录到达时原子地消失,而排队中的提交始终不进入 Chat。本包不组装或修改模型请求。 +使用本包可在浏览器中渲染已记录的 Session 对话,包括历史图片、本地化操作和滚动位置恢复。紧凑显示会收起已完成轮次的过程行,同时保持最终答案和独立有用的上下文可见;已打包的历史 Assistant 连续消息保持收起。本地 transcript(文本记录)与 steering(中途引导)提交会立即显示并保留在原区域,在权威会话记录到达时原子地消失,而排队中的提交始终不进入 Chat。本包不组装或修改模型请求。 -文件引用提供方同时接收当前查看的会话 ID 与收尾 turn 的属主信息,因此继承历史中的链接可以指向 fork 自身。 +文件提及提供方同时接收当前查看的会话 ID 与收尾轮次 的属主信息,因此继承历史中的链接可以指向 fork 自身。 ## 目录 @@ -27,19 +27,21 @@ kind: "package-reference" ## 系统提示词行 -每个非空追加的 `system/message` 都拥有一行折叠提示词,包括无 header 窗口起点的完整提示词;同一步骤的 header 不会重复它。Chat 也会为非空的初始请求、显式消息序列起点、文本发生变化的 `system/message` surface 节点替换(文本读取自 `request/header` 处 surface 顺序中最后一个非空存活系统节点),或前序 header 尚未进入已加载历史窗口的非初始请求显示一行默认折叠的 `System prompt`。即使系统文本未变,恢复也会重复该行,包括分页补齐前序 header 和系统节点后;同一序列内仅配置或仅工具变化、工具步骤与重试不会重复,且 `system/message` 事件绝不会渲染为对话消息。该行位于请求的用户消息之前,与提供方 envelope 顺序一致;展开后显示模型所见的确切文本,并保留其原始换行。系统节点为空或位于已加载窗口之外的请求不创建该行,直到包含该节点的页面加载后才创建该行。 +每个非空追加的 `system/message` 都拥有一行折叠提示词,包括无 header 窗口起点的完整提示词;同一步骤的 header 不会重复它。Chat 也会为非空的初始请求、显式消息序列起点、文本发生变化的 `system/message` surface 节点替换(文本读取自 `request/header` 处 surface 顺序中最后一个非空存活系统节点),或前序 header 尚未进入已加载历史窗口的非初始请求显示一行默认折叠的 `系统提示词`。即使系统文本未变,恢复也会重复该行,包括分页补齐前序 header 和系统节点后;同一序列内仅配置或仅工具变化、工具步骤与重试不会重复,且 `system/message` 事件绝不会渲染为对话消息。该行位于请求的用户消息之前,与提供方 envelope 顺序一致;展开后显示模型所见的确切文本,并保留其原始换行。系统节点为空或位于已加载窗口之外的请求不创建该行,直到包含该节点的分页到达。 + +----- ## 轮次 token 用量 -只有当已加载窗口包含 `turn/start`,且每次已启动的模型尝试都报告安全、精确的用量时,已完成轮次才显示可展开的用量行。该行会省略不可用的可选用量桶。记账不完整或相互矛盾时,整项用量信息都不显示,避免把部分总量冒充完整结果。 +只有当已加载窗口包含 `turn/start`,且每次已启动的模型尝试都报告安全、精确的用量时,已完成轮次才显示可展开的用量行。该行会省略不可用的可选用量桶。记账不完整或相互矛盾时,整个详情都不显示,避免把部分总量冒充完整结果。 ----- ## 轮次过程折叠 -「设置 → 通用设置」提供持久化到 `ui-chat` 命名空间的 `Normal` / `Compact` 对话显示偏好,默认使用 `Compact`。Normal 保持所有过程行可见且不渲染轮次过程控件。Compact 模式下,系统提示词在整个轮次中始终独立显示于开场 User 上方。轮次打开期间,上下文注入、推理、Assistant 内容、工具行与重试行始终展开。到 `turn/end` 时,最后一个步骤只有在包含非空文本、图片或未知可见块且不含工具调用块时才成为最终答案边界;边界之前的上下文注入、推理、较早 Assistant 内容、工具行与重试行随后默认收起。控件展示覆盖整个轮次的非 subagent 工具调用数、最终答案之前带回复内容的 Assistant 消息数和 subagent 委派数;值为 0 的分段省略,工具调用与 subagent 两项互斥,系统提示词与上下文注入都不增加计数。三项全为 0 时过程仍会收起,控件显示 `Thought for a while`。摘要下方的通栏分隔线将其与正文或展开后的过程行隔开。用户与 steering 消息、系统提示词、错误、最大 token 与 turn-tail 行留在过程组外;关闭时没有最终答案的轮次保留全部过程证据。新的过程控件插入时不会改变既有行的相对顺序:开场人工输入从首次投影起便位于控件和过程行之前,系统提示词则始终位于该输入上方。只要仍可通过「加载更早」获取历史,过程控件就不出现,也不会隐藏任何成员;历史加载完整后,每个合格的已关闭轮次立即使用默认收起状态。稳定 Chat Node Seat 会让每个 renderer 保持挂载,隐藏成员不产生消息流间距;只有中间没有独立输入时,收起控件才位于最终答案上方 8px 处。完成后的收起不依赖是否跟随尾部,因此正在上方阅读的用户可能看到 transcript 重排。若自动收起会隐藏当前键盘焦点,则过程组保持展开且焦点留在原处;手动收起会先把焦点移到过程控件,再隐藏成员。会话作用域存储只记录用户手动展开的「轮次 + 正文步骤」generation;不同正文 generation 默认收起。 +「设置 → 通用设置」提供持久化到 `ui-chat` 命名空间的「标准」/「紧凑」对话显示偏好,默认使用「紧凑」。标准模式保持所有过程行可见且不渲染轮次过程控件。紧凑模式下,系统提示词在整个轮次中始终独立显示于开场 User 上方。轮次打开期间,上下文注入、推理、Assistant 内容、工具行与重试行始终展开。到 `turn/end` 时,最后一个步骤只有在包含非空文本、图片或未知可见块且不含工具调用块时才成为最终答案边界;边界之前的上下文注入、推理、较早 Assistant 内容、工具行与重试行随后默认收起。控件展示覆盖整个轮次的非 subagent 工具调用数、最终答案之前带回复内容的 Assistant 消息数和 subagent 委派数;值为 0 的分段省略,工具调用与 subagent 两项互斥,系统提示词与上下文注入都不增加计数。三项全为 0 时过程仍会收起,控件标题显示「已思考」(英文为 `Thought for a while`)。摘要下方的通栏分隔线将其与正文或展开后的过程行隔开。用户与 steering 消息、系统提示词、错误、最大 token 与 turn-tail 行留在过程组外;关闭时没有最终答案的轮次保留全部过程证据。新的过程控件插入时不会改变既有行的相对顺序:开场人工输入从首次投影起便位于控件和过程行之前,系统提示词则始终位于该输入上方。只要仍可通过「加载更早」获取历史,过程控件就不出现,也不会隐藏任何成员;历史加载完整后,每个合格的已关闭轮次立即使用默认收起状态。稳定 Chat Node Seat 会让每个 renderer 保持挂载,隐藏成员不产生消息流间距;只有中间没有独立输入时,收起控件才与正文相隔 8px。完成后的收起不依赖是否跟随尾部,因此正在上方阅读的用户可能看到 transcript 重排。若自动收起会隐藏当前键盘焦点,则过程组保持展开且焦点留在原处;手动收起会先把焦点移到过程控件,再隐藏成员。会话作用域存储只记录用户手动展开的「轮次 + 正文步骤」generation;不同正文 generation 默认收起。 ----- diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index 7a5cb18771..ea9e994e91 100644 --- a/packages/client/ui-chat/package.json +++ b/packages/client/ui-chat/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-chat", "description": "Chat Conversation target, node definitions, renderers, and details surface", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.module.css b/packages/client/ui-chat/src/client/chat/MessageItem.module.css index 2f44408b33..8507492a70 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-chat/src/client/chat/MessageItem.module.css @@ -317,7 +317,7 @@ .fileIcon { flex: none; - width: 24px; + width: 28px; height: 28px; } diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.tsx b/packages/client/ui-chat/src/client/chat/MessageItem.tsx index fc4f257048..f4e0fffa54 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-chat/src/client/chat/MessageItem.tsx @@ -2,7 +2,7 @@ import { Fragment, memo, useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' import type { PendingSubmission } from '@deepseek-ai/dsh-api-session-controller/client' import type { MessageImageSource } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { DocumentFileIcon, fileSizeText, JsonBlock, projectUserText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' +import { fileExtension, FileTypeIcon, fileSizeText, JsonBlock, projectUserText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts' import type { ModelRetryNode, TurnErrorNode, UserMessageNode } from '../contract/snapshot.ts' import { CompactionItem } from './CompactionItem.tsx' @@ -16,12 +16,6 @@ type PresentedAttachment = | { readonly type: 'image'; readonly image: MessageImageSource } | { readonly type: 'file'; readonly file: UserFile['attachment'] } -function extensionOf(name: string): string { - const dot = name.lastIndexOf('.') - if (dot <= 0 || dot === name.length - 1) return '' - return name.slice(dot + 1).toUpperCase().slice(0, 8) -} - function contentParts(content: readonly unknown[]): { text: string attachments: PresentedAttachment[] @@ -206,11 +200,11 @@ function UserStyleBubble({ ) : ( - + {attachment.file.name} - {[extensionOf(attachment.file.name), fileSizeText(attachment.file.bytes)] + {[fileExtension(attachment.file.name).toUpperCase().slice(0, 8), fileSizeText(attachment.file.bytes)] .filter(Boolean).join(' ')} diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index 51c7bf6072..fcae47bcef 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -29,8 +29,8 @@ export const zh = { 'chat.turnNavigation.turn': '第 {turn} 轮', 'settings.transcript.title': '对话显示', 'settings.transcript.description': '控制已完成轮次的过程内容', - 'settings.transcript.normal': 'Normal', - 'settings.transcript.compact': 'Compact', + 'settings.transcript.normal': '标准', + 'settings.transcript.compact': '紧凑', 'fileOpen.title': '无法打开文件', 'fileOpen.unknown': '无法打开此文件', 'message.extraBlock': '附加内容块', diff --git a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx index 5991449f44..a349f34576 100644 --- a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx @@ -1085,6 +1085,10 @@ describe('user file attachments', () => { expect(view.getByTitle('notes.pdf').textContent).toContain('3.2MB') expect(view.getByTitle('tiny.txt').textContent).toContain('12B') expect(view.getByTitle('mid.csv').textContent).toContain('500KB') + const icons = ['notes.pdf', 'tiny.txt', 'mid.csv'].map(name => + view.getByTitle(name).querySelector('svg')?.innerHTML, + ) + expect(new Set(icons).size).toBe(icons.length) expect(view.getByText('summarize these')).toBeTruthy() }) }) diff --git a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx index b75f62a33b..288bdce31d 100644 --- a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx +++ b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx @@ -8,7 +8,7 @@ import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { TranscriptViewRow, type TranscriptViewRowProps } from '../src/client/settings/TranscriptViewRow.tsx' -import { en } from '../src/client/locale.ts' +import { en, zh } from '../src/client/locale.ts' afterEach(cleanup) @@ -31,7 +31,7 @@ function noPendingInteraction() { // The resource hook the resources plugin merges into GlobalStandardProps; this row reads no address. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource'] -function mount(mode: 'normal' | 'compact' = 'compact') { +function mount(mode: 'normal' | 'compact' = 'compact', dictionary: typeof en | typeof zh = en) { const source = createSnapshotStore(mode) const setTranscriptView = vi.fn((next: 'normal' | 'compact') => { source.set(next) }) const props: TranscriptViewRowProps = { @@ -42,7 +42,7 @@ function mount(mode: 'normal' | 'compact' = 'compact') { useResource, useTranscriptView: bindSnapshotSelector(source), setTranscriptView, - t: makeTranslate(en), + t: makeTranslate(dictionary), } render() return { setTranscriptView } @@ -67,4 +67,11 @@ describe('TranscriptViewRow', () => { fireEvent.pointerDown(document.body) expect(screen.queryByRole('menuitem', { name: 'Compact' })).toBeNull() }) + + it('shows the conversation-display values in Chinese', () => { + mount('compact', zh) + fireEvent.click(screen.getByRole('button', { name: '紧凑' })) + fireEvent.click(screen.getByRole('menuitem', { name: '标准' })) + expect(screen.getByRole('button', { name: '标准' })).toBeDefined() + }) }) diff --git a/packages/client/ui-commands/README.i18n.yaml b/packages/client/ui-commands/README.i18n.yaml index 52d6166365..4557db8d9f 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: 9bd0c6f9a86762187b79f5583ec56fd895d39ff8 -README.zh.md: 2405fbcce9a10f544307f18787875562063ff965 +README.md: 67c05bb2cd22f63e386c4646dfb3472d9e01eea1 +README.zh.md: f5701c491bd4127106a32d936f6434c1e572e3e5 diff --git a/packages/client/ui-commands/README.md b/packages/client/ui-commands/README.md index 9bd0c6f9a8..67c05bb2cd 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 2405fbcce9..f5701c491b 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`。 ## 目录 @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -与 `ui-input-trigger` 及 `ui-conversation` 一起挂载本插件;`/` source 随即出现在触发菜单中,业务包经 `ctx.commandUi` 注册相应的命令交互方式。键入 `/model` 打开已注册的弹窗;带参数声明的宿主命令打开其输入或直接执行。 +与 `ui-input-trigger` 及 `ui-conversation` 一起挂载本插件;`/` source 随即出现在触发菜单中,业务包经 `ctx.commandUi` 注册自己的命令表面。键入 `/model` 打开已注册的弹窗;带参数声明的宿主命令打开其输入或直接执行。 ### 种类与装饰 diff --git a/packages/client/ui-commands/package.json b/packages/client/ui-commands/package.json index 40ce5a3bff..4dc37a8261 100644 --- a/packages/client/ui-commands/package.json +++ b/packages/client/ui-commands/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-commands", "description": "Client command surface: global directory cache, '/' source, three command UI kinds, popupSelect registry", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, 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 96967e7280..d2836753f9 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: da8c84c918b870d1d5bf4d14d7fe0deeefd3bfe2 +README.md: 7d5f370644f90f57ce1c27c59becf47c4cf2e684 +README.zh.md: fe1d2726c8fd5b5438bd22d4b85a5b3bbcaac019 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 da8c84c918..fe1d2726c8 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`ui-conversation` 拥有与 target 无关的 Conversation 组装和共享浏览器 shell。它消费 Session Controller 的 `SessionEventLikeEntry` feed,通过 `ctx.uiConversation` 暴露不依赖 React 的注册表与逐 Session binding,并通过 `ctx.uiSession` 提供 `useConversation`、`useInput` 和 `inputActions` 标准 props。它还拥有按会话的持久化图片 URL 缓存:`ctx.uiConversation.imageUrl(sessionId, attachment)` 为每个附件解析一个经会话授权的浏览器 URL,并随 Session binding 释放而撤销,因此所有 Conversation target 共享一次 `session.attachment` 读取。Chat 等具体 target 位于独立包,由各自包注册 Definition、快照 builder、View 和 renderer。 +`ui-conversation` 拥有与 target 无关的 Conversation 组装和共享浏览器 shell。它消费 Session Controller 的 `SessionEventLikeEntry` feed,通过 `ctx.uiConversation` 暴露不依赖 React 的注册表 与逐 Session binding,并通过 `ctx.uiSession` 提供 `useConversation`、`useInput` 和 `inputActions` 标准 props。它还拥有按会话的持久化图片 URL 缓存:`ctx.uiConversation.imageUrl(sessionId, attachment)` 为每个附件解析一个经会话授权的浏览器 URL,并随 Session binding 释放而撤销,因此所有 Conversation target 共享一次 `session.attachment` 读取。Chat 等具体 target 位于独立包,由各自包注册 Definition、快照 builder、View 和 renderer。 ## 目录 @@ -25,13 +25,13 @@ kind: "package-reference" ## Conversation 组装 -`UiConversation.events` 是事件 Definition 的唯一注册表,`UiConversation.views` 是 target 快照 builder 的唯一注册表。两者都拒绝重复 key、保持注册顺序、返回幂等 disposer,并在 contribution roster 变化时重建现有 binding。`UiConversation.binding(bindingOrSessionId)` 为当前 Session Controller binding 返回 identity 稳定的 Conversation binding,不会另开事件源。 +`UiConversation.events` 是 event Definition 的唯一 registry,`UiConversation.views` 是 target snapshot builder 的唯一 registry。两者都拒绝重复 key、保持注册顺序、返回幂等 disposer,并在 contribution roster 变化时重建现有 binding。`UiConversation.binding(bindingOrSessionId)` 为当前 Session Controller binding 返回 identity 稳定的 Conversation binding,不会另开 事件源。 -适配器把每个 `SessionEventLikeEntry` 直接交给 assembler。外层 `type` 区分持久事件与 Client-only transient 事件,内部 `event` 则统一公开 `type`、`seq`、`time` 与 `data`;Definition 接收这个内部 `SessionEventLike`。replacement window 可以包含两种 entry,历史 prepend 携带持久 entry,实时 append 则可以携带任一种。两种事件都使用 Definition 的同一组 `match` 与 `update` 方法,`start` 只接收持久事件,assembler 会拒绝 transient start。不消费 Assistant delta 的 Definition 对 `assistant/live-chunk` 返回 `null`。replace window 或 revision 断档从完整已加载窗口重建;连续 revision 的 append、prepend 与 Assistant settlement 使用增量组装。settlement 只删除具名 attempt 的 transient match,应用可选持久 entry,并重放受影响的 Context 及其 dependent,不替换无关 target node。assembler 拥有 Context 匹配、Turn/Step location、target node 物化、target activity 和稳定 target source。`ConversationSnapshot` 只包含与 target 无关的 View 与 active-target 事实;Session lifecycle 状态仍属于 `SessionSnapshot`。 +适配器把每个 `SessionEventLikeEntry` 直接交给 assembler。外层 `type` 区分持久事件与 Client-only transient event,内部 `event` 则统一公开 `type`、`seq`、`time` 与 `data`;Definition 接收这个内部 `SessionEventLike`。replacement window 可以包含两种 entry,历史 prepend 携带持久 entry,实时 append 则可以携带任一种。两种事件都使用 Definition 的同一组 `match` 与 `update` 方法,`start` 只接收持久 event,assembler 会拒绝 transient start。不消费 Assistant delta 的 Definition 对 `assistant/live-chunk` 返回 `null`。replace window 或 revision 断档从完整已加载窗口重建;连续 revision 的 append、prepend 与 Assistant settlement 使用增量组装。settlement 只删除具名 attempt 的 transient match,应用可选持久 entry,并重放受影响的 Context 及其 dependent,不替换无关 target node。assembler 拥有 Context 匹配、Turn/Step location、target node 物化、target activity 和稳定 target source。`ConversationSnapshot` 只包含与 target 无关的 View 与 active-target 事实;Session lifecycle 状态仍属于 `SessionSnapshot`。 shell 选择解析出 target 或 target source 收到首个 subscriber 时,该 target 进入 active 状态。assembler 从当前 Context 对它执行一次 replace,并使它参与后续增量 flush;创建 source 不会激活 target,取消订阅也不会停用 target。 -target 包通过 declaration merge 扩展快照与 Location data map,再调用 `ctx.uiConversation.events.register(...)` 和 `ctx.uiConversation.views.register(...)`。target 通过 `ctx.uiConversation.binding(binding).target(targetId)` 读取其 Session-owned source。注册属于 Cordis effect,返回的 disposer 从同一个注册表移除 contribution。共享的请求检查服务于每个 target:`ctx.uiConversation.inspectSystemPrompt(previous, event)` 将系统消息与位置替换解释为不可变的已加载 surface 状态。它按 surface 顺序选择最后一个非空的存活系统节点,为连续重写只保留存活的替换位置;遇到未建立索引的更早端点后,提示词保持不可用,直到向前补页回放提供其顺序。target 自有的 Definition 独立保留历史卡片。`ctx.uiConversation.inspectRequestPrompt(previous, header, system)` 根据该有效提示词分类请求变更;普通消息与流式分片无需处理系统状态。 +target package 通过 declaration merge 扩展 snapshot 与 Location data map,再调用 `ctx.uiConversation.events.register(...)` 和 `ctx.uiConversation.views.register(...)`。target 通过 `ctx.uiConversation.binding(binding).target(targetId)` 读取其 Session-owned source。注册属于 Cordis effect,返回的 disposer 从同一个 registry 移除 contribution。共享的请求检查服务于每个 target:`ctx.uiConversation.inspectSystemPrompt(previous, event)` 将系统消息与位置替换解释为不可变的已加载 surface 状态。它按 surface 顺序选择最后一个非空的存活系统节点,为连续重写只保留存活的替换位置;遇到未建立索引的更早端点后,提示词保持不可用,直到向前补页回放提供其顺序。target 自有的 Definition 独立保留历史卡片。`ctx.uiConversation.inspectRequestPrompt(previous, header, system)` 根据该有效提示词分类请求变更;普通消息与流式分片无需处理系统状态。 ## Shell 与标准 props @@ -44,13 +44,13 @@ 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 操作通过作用域内的 `ctx.conversation` 服务寻址准确的 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` 时不会再次读取这些浏览器文件。提示词复用提交 `requestId`;queue 或历史以同一 `rpcId` 被观察后,回显只退休一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 在接纳期间以及 Session scope 释放过程中持续持有附件 id。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation 服务在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。可继续 subagent 禁用附件入口,也不创建本地回显,因为其 transport 不保留浏览器 request id。 +默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript(文本记录),繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。提示词复用提交 `requestId`;queue 或历史以同一 `rpcId` 被观察后,回显只退休一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation 服务在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。continuable 子代理禁用附件入口,也不创建本地回显,因为其 transport 不保留浏览器 request id。 排队提交的本地回显在禁用的编辑、删除、插话按钮旁显示“发送中…”;折叠后的队列在标题栏保留发送状态。匹配的 Host 队列行替换回显后,各操作按原有的纯文本内容和运行状态要求启用。仅收到提示词确认不会启用队列操作。提交失败会移除回显并显示错误;输入框为空或仍保留上一次自动恢复的内容时,composer 恢复失败草稿,保留用户随后输入的文字。 -Send 和 Stop 按钮禁用时不显示提示气泡,轮次结束后由 Stop 切换成禁用 Send 的按钮也遵循此规则。普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置为普通 Session 与可继续子会话选择 Queue 或 Steer 投递,运行中的 Send 按钮按 plain Enter 解析出的同一模式投递;当它在普通消息草稿上可用(没有待上传文件)时,其标签以该模式命名(排队发送或插话发送),因此该设置同时约束 Enter 与按钮,而 Cmd/Ctrl+Enter 仍使用另一模式;空闲会话、空草稿与 `/` 命令行保留普通的 Send 标签([决策](../../../.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.zh.md))。它们的 QueueDock 行共享 Edit、Remove 与 Steer,空草稿也共享 steer-all 组合键。一次性子会话继续只读。Plan Mode 与 active goal 不改变附件入口。可继续子会话保留独立的 Send 与 Stop 操作,但不提供回形针、粘贴或拖放入口;父会话离线时,Send 与 composer 手势锁定,但在线 inbox 的 QueueDock 控制仍可使用([决策](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md)、[inbox 控制](../../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.zh.md))。 +Send 和 Stop 按钮禁用时不显示提示气泡,轮次结束后由 Stop 切换成禁用 Send 的按钮也遵循此规则。普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置为普通 Session 与可继续 child 选择 Queue 或 Steer 投递,运行中的 Send 按钮按 plain Enter 解析出的同一模式投递;当它在普通消息草稿上可用(没有待上传文件)时,其标签以该模式命名(排队发送或插话发送),因此该设置同时约束 Enter 与按钮,而 Cmd/Ctrl+Enter 仍使用另一模式;空闲会话、空草稿与 `/` 命令行保留普通的 Send 标签([决策](../../../.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.zh.md))。它们的 QueueDock 行共享 Edit、Remove 与 Steer,空草稿也共享 steer-all 组合键。One-shot child 继续只读。Plan Mode 与 active goal 不改变附件入口。可继续 child 保留独立的 Send 与 Stop 操作,但不提供回形针、粘贴或拖放入口;parent 离线时,Send 与 composer 手势锁定,但在线 inbox 的 QueueDock 控制仍可使用([决策](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md)、[inbox 控制](../../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.zh.md))。 ## 临时 composer entry @@ -69,7 +69,7 @@ interface ComposerChainProps { } ``` -业务包仅可安装一个 entry,且仅限于 Remote waterfall(瀑布式事件)请求处于 pending 状态期间: +业务包仅可在一个 Remote waterfall request pending 期间安装 entry: ```tsx import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -98,7 +98,7 @@ try { } ``` -selector 必须是 owner currency 的纯函数。非 null 返回值作为 `matched` 传给组件;`PropsRuntime<'conversation.composer'>` 提供标准 Session 与 global props。Chain 顺序仍按 `priority` 升序,再按注册顺序;首个返回非 null 的 selector 获选。Shell 会在 takeover 下保持默认 composer 挂载。Request 状态、listener、response encoding 和任何 request-specific child slot 都属于业务包,不进入 `SessionSnapshot`,也不由 core 包声明。 +selector 必须是 owner currency 的纯函数。非 null 返回值作为 `matched` 传给组件;`PropsRuntime<'conversation.composer'>` 提供标准 Session 与 global props。Chain 顺序仍按 `priority` 升序,再按注册顺序;首个返回非 null 的 selector 获选。Shell 会在 takeover 下保持默认 composer 挂载。Request 状态、listener、response encoding 和任何 request-specific child slot 都属于业务 package,不进入 `SessionSnapshot`,也不由 core 包声明。 ## 模型体验 @@ -107,7 +107,7 @@ selector 必须是 owner currency 的纯函数。非 null 返回值作为 `match #### KV Cache 影响 -无;Conversation 组装和浏览器输入状态不会改变提供方侧的提示词缓存。 +无;Conversation 组装和浏览器输入状态不会改变提供方侧的 prompt cache。 ## 已知限制与暂缓事项 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 987aa9574f..c1e62e4100 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-conversation", "description": "Target-neutral Conversation assembly, shell, composer, queue, and view navigation", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index cf76e02b1b..795090f465 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -143,10 +143,9 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } /** * The header's far-right corner, past the utilities' edge and into the - * header's own padding, for one control that must keep its place whether or - * not it currently shows anything. The corner reserves its width while an - * occupant is registered, so the utilities beside it never move; an - * occupant with nothing to show renders a same-size placeholder. + * header's own padding, for one control. The corner is laid out only while + * its occupant renders something; an occupant with nothing to show renders + * nothing, and the utilities take the header's edge. */ 'conversation.session.header.corner': { kind: 'single' diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index 0dcbdda890..3492d37686 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -5,7 +5,7 @@ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots import type { SessionId } from '@deepseek-ai/dsh-session/types' import { IconCheckOutline16, IconChevronDownOutline14, IconChevronUpOutline14, IconCloseOutline16, - DocumentFileIcon, fileSizeText, IconEditOutline16, IconQueueOutline14, IconSendOutline14, + FileTypeIcon, fileSizeText, IconEditOutline16, IconQueueOutline14, IconSendOutline14, IconTrashOutline16, projectUserText, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { QueueAction, QueueItemId, QueueRow } from '../contract/queue.ts' @@ -52,7 +52,7 @@ function queueAttachments(content: QueueRow['content']): Array< function QueueFile({ attachment, label }: { attachment: FileAttachmentRef; label: string }) { return ( - + {attachment.name} {fileSizeText(attachment.bytes)} diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index f988537220..22420cc8d8 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -34,23 +34,17 @@ --dsh-composer-dock-inset: 8px; } +/* 76px with its rule: the height of the Sidebar's tab strip and header row + (ui-dockkit `.tabStrip` 38px + the pane header's 38px), so the two rules + meet at the column edge. The rows add up to it — 10px top inset, the 30px + title row, the tab strip's 10px margin, a 16px tab line, and its 9px bottom + inset — so no row below may grow past its figure. */ .header { - position: relative; flex: none; - padding: 12px 28px 0 20px; - border-bottom: 1px solid transparent; -} - -.header::after { - content: ''; - position: absolute; - right: 0; - bottom: 1px; - left: 0; - z-index: 0; - height: 0.5px; - background: var(--dsw-alias-border-l3); - pointer-events: none; + box-sizing: border-box; + min-height: 76px; + padding: 10px 28px 0 20px; + border-bottom: 0.5px solid var(--dsw-alias-border-l3); } /* Blank hero/settling: keep the strict Session header mounted without taking @@ -63,7 +57,7 @@ display: flex; align-items: center; gap: 0; - min-height: 32px; + min-height: 30px; } .titleCluster { @@ -147,14 +141,14 @@ } /* The far-right corner seat reaches 16px into the header's 28px right padding, - so its control sits past the utilities' edge; it is laid out only while an - occupant is registered, and the occupant keeps its width while hidden, so the - utilities never move because of it. */ + so its control sits past the utilities' edge; it is laid out only while its + occupant renders something. Its left margin equals the utilities' 8px gap so + the header's trailing controls space evenly. */ .headerCorner { display: flex; flex: none; align-items: center; - margin-left: 12px; + margin-left: 8px; margin-right: -16px; } @@ -162,20 +156,23 @@ display: none; } -/* figma Tab_Group 34:11441: 35px strip, gap 36, pad-left 8, tabs bottom-aligned. */ +/* figma Tab_Group 34:11441: 35px strip, gap 36, pad-left 8, tabs bottom-aligned. + Positioned above the header's own paint, so the active bar covers the rule. */ .tabs { position: relative; z-index: 1; display: flex; gap: 36px; - margin-top: 4px; + margin-top: 10px; padding-left: 8px; } -/* figma .Tab 34:11442: 13/16 text (figma wt510, rendered 500), gap 8 to the 2px bar. */ +/* figma .Tab 34:11442: 13/16 text (figma wt510, rendered 500). The 2px bar + reaches 1px past the tab's box to end flush with the header's bottom edge, + sitting over the rule. */ .tab { position: relative; - padding: 0 0 11px; + padding: 0 0 9px; border: none; background: transparent; font-size: 13px; @@ -189,7 +186,7 @@ content: ''; position: absolute; right: 0; - bottom: 1px; + bottom: -1px; left: 0; height: 2px; border-radius: 2px; @@ -346,6 +343,10 @@ flex: 1; flex-direction: column; min-height: 0; + /* The product-wide 2px scrollbar offset (ui-workspace WorkspaceBrowser + `.list`, ui-sidebar-files FilesBody `.body`): the bar sits 2px clear of + the column's edge instead of flush against it. */ + margin-right: 2px; overflow-y: auto; /* Reserved unconditionally: the composer seat rides this box's content box in Chat and its padding box under a view's composer overlay, so an `auto` @@ -354,6 +355,12 @@ scrollbar-gutter: stable; } +/* The bar also stops 2px short of the scroller's ends, matching its 2px edge + offset. WebKit-only: the Firefox path has no track to inset. */ +.scrollBody::-webkit-scrollbar-track { + margin: 2px; +} + .root[data-phase='active'] .viewArea { flex: 1 0 auto; min-height: auto; 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 d3cd782313..fc924cf1c3 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: 1ad7f623f423560addd80bc84bcb6cbb74c2347f +README.md: 857b71c5e0f3e4ba0dac0e8453492684c76ca0e4 +README.zh.md: 438e53e4bae5a81906517ac3a1e9ba6187d6aff1 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index ae42abb5a6..857b71c5e0 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -30,13 +30,13 @@ Mount this plugin alongside `ui-conversation`; a finished turn then ends with th ### Explicit deliveries -The Web `standard`, `ptc`, and `cordis` presets expose `present` for final workspace files, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../fs/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows responsive cards with file names, types, descriptions, and buttons that open the source in the Host’s default application. Matching inline-code references open the same source files without starting a browser download. Repeated declaration of a path selects its latest description before the closing reply. +The Web `standard`, `ptc`, and `cordis` presets expose `present` for final files accessible through the Session filesystem, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../fs/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows one delivery as a full-width card and multiple deliveries in a grid of at most two cards per row. A list longer than four files starts collapsed and provides a control that reveals or hides the complete list. Each card uses the shared `FileTypeIcon` and shows the basename and description, or the file type when no description exists; a trailing parenthesized suffix in the description is omitted, and hovering the card replaces that line with the Sidebar-preview action. Clicking the card or the left side of its split Open control previews the file in the right Sidebar. The chevron opens the standard menu for the Host default application plus Show in Finder on macOS, Show in File Explorer on Windows and WSL, or Open containing folder through the default Linux file manager. Matching inline-code references open the same source files without starting a browser download. Repeated declaration of a path selects its latest description before the closing reply. -The `present` tool row shows running, delivered, failed, or interrupted status; expanding a settled row reveals its recorded result. File cards include every delivered file. Opening shows progress, confirmation, or a retryable error on the card. It requires a desktop and a suitable default application on the serving Host; a remote browser does not open applications on its own device. +The `present` tool row shows running, delivered, failed, or interrupted status; expanding a settled row reveals its recorded result. The collapsible card grid retains every delivered file. Both menu actions share pending state and show progress, acknowledgement, or an action-specific retryable error. Desktop information is read when delivery cards appear and invalidated on connection replacement; responses from a replaced connection cannot publish metadata. Selecting a native menu action returns keyboard focus to the available Sidebar Open button. Pending actions close the menu until another explicit gesture. A missing desktop disables the Open menu; a failed desktop-information read offers Retry. It requires a desktop and a suitable default application on the serving Host; a remote browser does not open applications on its own device. ### 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 @@ -52,7 +52,7 @@ The closing prose carries the same vocabulary: an inline-code token resolves by The Node half registers the static `ui:deliverable-file-references` system-prompt section asking the model to mention primary files from successful creation or modification calls and to write those and any other changed-file references as Markdown inline code. The browser half registers a wrapper around `ProducedFiles` and explicit deliveries into the chat view's `conversation.chat.turnTail` hole. `deliverablesDefinition` folds each Turn's successful first-party mutation calls into `DeliverablesTurnData` from the validated raw arguments of `write`, `edit`, and mutating `str_replace_editor` commands. Reads, deletes, unsupported tools, malformed calls, and failed results contribute nothing. A new mutation tool needs an explicit Client contribution before it joins the list. The package also provides the `chatFileMentions` service the chat view consults per closing message; composing the plugin out removes both surfaces and leaves the view's empty chain at zero cost. -Native opening uses an authenticated POST addressed by the viewed Session, event sequence, and original file index. The Host resolves the declaration against that Session’s workspace and checks the current file exists within it before launching the default application. Edits affect subsequent opens; deletion returns an error. No file-content copy or attachment is created. Plugin disposal cancels and awaits pending native-open requests. +Native opening uses an authenticated POST addressed by the viewed Session, event sequence, and original file index. The Host reads the viewed Session header with the declaration and passes its cwd, or the deployment workspace root when absent, to `workspaceFiles.stat`. This uses the same composed filesystem as Sidebar previews and does not activate an Agent, including for child Sessions. Native actions require the canonical process path to map from a Host path back to that same process path. Providers without this mapping return 422 and the card directs the user to Sidebar preview; a same-named Host file is insufficient. The same configured desktop availability governs metadata and execution. Edits affect subsequent opens; deletion returns an error. No file-content copy or attachment is created. Plugin disposal cancels and awaits pending native-open requests. @@ -96,7 +96,7 @@ These limits define the current deliverables vocabulary. They are current packag - **Mention matching is exact path or unique basename only** — a suffix mention stays inert; widening the matcher is deferred until a real closing-message shape needs it. - **Terminal-created files require explicit delivery** — call `present` to declare them for native opening. -- **Declarations do not preserve file contents** — reopening or transferring a Session requires the source files in the viewed Session’s workspace. Missing files return 404; paths resolving outside the workspace return 403. +- **Declarations do not preserve file contents** — reopening or transferring a Session requires source files accessible through the viewed Session’s filesystem. Missing files, directories, and final symbolic links return 404. - **Directories have no destination** — chips open files in the right Sidebar's text preview, which shows files only; the former native folder handoff is gone rather than replaced. @@ -109,4 +109,4 @@ None. -**Runtime invariant:** No companion is published. Prompt, slot, dictionary, file-action route, and optional service registrations are effect-owned; the Session log owns declarations and the workspace owns file contents. +**Runtime invariant:** No companion is published. Prompt, slot, dictionary, file-action route, and optional service registrations are effect-owned; the Session log owns declarations and the filesystem owns file contents. diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 1ad7f623f4..438e53e4ba 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -25,18 +25,18 @@ kind: "package-reference" ## 使用本包 -与 `ui-conversation` 一起挂载本插件;已完成轮次随即以产出文件行收尾,位于收尾消息正文与其动作页脚之间。每个标签项经属主的 `openFile` 打开文件——chat 视图把它路由到右侧 Sidebar 作为一个文本预览 tab——相对路径按会话 cwd 解析。该行不提供文件夹动作:Sidebar 没有目录形态,因此省略文件的余数只是一个标签。 +与 `ui-conversation` 一起挂载本插件;已完成轮次随即以产出文件行收尾,位于收尾消息正文与其动作页脚之间。每个标签项经属主的 `openFile` 打开文件——chat 视图把它路由到右侧 Sidebar 作为一个文本预览 tab——相对路径按会话 cwd 解析。该行不提供文件夹动作:Sidebar 没有目录形态,因此省略项只显示为标签。 ### 显式交付 -Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付最终工作区文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有文件数量限制和会话声明。收尾轮次显示响应式卡片,包含文件名称、类型、说明和在 Host 默认应用中打开源文件的按钮。匹配的行内代码引用打开相同源文件,不触发浏览器下载。同一路径重复声明时,选择收尾回复之前最近一次的说明。 +Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付会话文件系统可访问的最终文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有文件数量限制和会话声明。收尾轮次把单个交付显示为横向占满内容区的卡片,把多个交付显示为每行最多两张卡片的网格。文件超过四个时,列表默认收起,并提供显示或隐藏完整列表的控件。每张卡片使用共享的 `FileTypeIcon`,显示 basename 与说明;没有说明时显示文件类型,说明末尾的括号后缀会被省略,悬停卡片时该行切换为侧栏预览提示。点击卡片或分段“打开”控件的左侧会在右侧 Sidebar 中预览文件;右侧箭头打开标准菜单,其中提供 Host 默认应用,以及 macOS 上的“在 Finder 中显示”、Windows 和 WSL 上的“在文件资源管理器中显示”或 Linux 默认文件管理器的“打开所在文件夹”。匹配的行内代码引用打开相同源文件,不触发浏览器下载。同一路径重复声明时,选择收尾回复之前最近一次的说明。 -`present` 工具行显示正在交付、已交付、失败或中断状态;展开已结束的调用可查看其记录的结果。文件卡片展示全部交付文件。打开时,卡片显示进度、成功确认或可重试的错误。服务 Host 必须具备桌面和合适的默认应用;远程浏览器不会打开其所在设备上的应用。 +`present` 工具行显示正在交付、已交付、失败或中断状态;展开已结束的调用可查看其记录的结果。可折叠卡片网格保留全部交付文件。菜单中的两个操作共享等待状态,并显示进度、成功确认或各自可重试的错误。交付卡片出现时读取桌面信息,连接更换时清除缓存,旧连接的响应不能更新元数据。选择原生菜单操作后,键盘焦点回到仍可用的侧边栏“打开”按钮。等待操作完成时关闭菜单,用户再次点击才会打开。Host 没有桌面时禁用“打开”菜单;桌面信息读取失败时提供“重试”。服务 Host 必须具备桌面和合适的默认应用;远程浏览器不会打开其所在设备上的应用。 ### 该行 -该行通过 CSS 容器宽度档位响应式展示至多六个文件标签项。Flexbox 负责收缩文件名并用省略号截断,CSS 为未展示路径选择匹配的本地化 `+ N 个文件` 标签;完整路径仍保留在 `title` 中,该行不执行 JavaScript 布局观察,也不提供横向滚动。 +“本轮文件改动”行列出成功的文件工具修改;最终文件交付需要调用 `present`。该行通过 CSS 容器宽度档位响应式展示至多六个文件标签项。Flexbox 负责收缩文件名并用省略号截断,CSS 为未展示路径选择匹配的本地化 `+ N 个文件` 标签;完整路径仍保留在 `title` 中,该行不执行 JavaScript 布局观察,也不提供横向滚动。 ### 行内代码链接 @@ -50,9 +50,9 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交
实现细节——点击展开 -Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,要求模型点名成功创建或修改的主要文件,并把这些文件以及正文中提到的其他本轮变更文件写成 Markdown 行内代码。浏览器半部把围绕 `ProducedFiles` 和显式交付的包装组件注册到 chat 视图的 `conversation.chat.turnTail` slot。`deliverablesDefinition` 根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数,把每个轮次成功的第一方修改调用折叠进 `DeliverablesTurnData`。读取、删除、不受支持的工具、格式错误的调用和失败结果不贡献任何条目。新的修改工具必须增加显式 Client contribution 才能加入列表。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;在组合中排除本插件会同时移除这两处界面,并以零成本保留视图中的空链。 +Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,要求模型点名成功创建或修改的主要文件,并把这些文件以及正文中提到的其他本轮变更文件写成 Markdown 行内代码。浏览器半部把组合 `ProducedFiles` 与显式交付的包装组件注册进 chat 视图的 `conversation.chat.turnTail` 洞。`deliverablesDefinition` 根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数,把每个轮次成功的第一方修改调用折叠进 `DeliverablesTurnData`。读取、删除、不受支持的工具、格式错误的调用和失败结果不贡献任何条目。新的修改工具必须增加显式 Client contribution 才能加入列表。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会同时移除两个表面,视图的空链以零成本留下。 -原生打开使用经过认证的 POST,通过当前查看的会话、事件序号和原始文件索引定位声明。Host 按该会话的工作区解析路径,检查当前文件存在且位于工作区内,再启动默认应用。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。 +原生打开使用经过认证的 POST,通过当前查看的会话、事件序号和原始文件索引定位声明。Host 读取声明及当前查看的 会话 header,将其中的 cwd 传给 `workspaceFiles.stat`;未记录 cwd 时使用部署的工作目录。它与侧栏预览使用同一组合文件系统,无需启动 Agent,子会话也适用。原生操作要求规范化的进程路径能从 Host 路径映射回同一进程路径。提供方没有这种映射时返回 422,卡片提示使用侧栏预览;Host 上存在同名文件并不足够。同一份桌面可用性配置同时约束信息查询和实际执行。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。
@@ -61,9 +61,9 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段, ## 进一步探索 -当仅了解产出文件界面还不够时,请阅读以下页面。它们从该行讲到 turn-tail slot 以及词表背后的决策。 +当产出物面不够用时阅读以下页面。它们从该行进入 turn-tail 洞与词表背后的决策。 -- [ui-conversation](../ui-conversation/README.zh.md)——声明 `conversation.chat.turnTail` slot 并渲染收尾正文。 +- [ui-conversation](../ui-conversation/README.zh.md)——声明 `conversation.chat.turnTail` 洞并渲染收尾正文。 - [工作区文件链接](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md)——产出文件行背后的决策;其 Host 打开路径已被[右侧 Sidebar](../../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md)取代。 - [行内文件提及](../../../.agents/notes/archived/feature/2026-08-07-web-inline-file-mentions.md)——收尾正文可点击提及背后的决策。 - [客户端包映射](../README.zh.md)——相邻的浏览器 UI 包。 @@ -94,10 +94,10 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段, 这些限制界定了当前产出物词表。它们是当前包约束,不是通用文件链接对比或任务积压。 -- **提及匹配只认精确路径或唯一 basename**——后缀式提及保持不可点击;等真实的收尾消息形态产生需求后再放宽匹配规则。 +- **提及匹配只认精确路径或唯一 basename**——后缀式提及保持惰性;等真实的收尾消息形态产生需求后再放宽匹配规则。 - **终端创建的文件需要显式交付**——调用 `present` 声明文件,以便原生打开。 -- **声明不保存文件内容**——重新打开或转移会话后,需要当前查看的会话工作区中仍有源文件。文件缺失返回 404;解析到工作区外的路径返回 403。 -- **目录没有打开目标**——标签项在右侧 Sidebar 的文本预览中打开文件,该预览仅支持文件;原有的原生文件夹交接已移除,并未被其他方式取代。 +- **声明不保存文件内容**:重新打开或转移 Session 后,源文件仍需能被当前查看的 Session 文件系统访问。文件缺失、为目录或最终路径为符号链接时返回 404。 +- **目录没有打开目标**——标签项在右侧 Sidebar 的文本预览中打开文件,该预览仅支持文件,不提供原生文件夹打开动作。 ### 开发备注 @@ -109,4 +109,4 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段, -**运行时不变式:** 不发布伴生入口。提示词、slot、dictionary、文件操作路由与可选服务注册归 effect 所有;会话日志拥有声明,工作区拥有文件内容。 +**运行时不变式:** 不发布伴生入口。提示词、slot、dictionary、文件操作路由与可选 service 注册归 effect 所有;Session 日志拥有声明,文件系统拥有文件内容。 diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index 67970786a0..dcac6ce807 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-deliverables", "description": "Produced-files turn tail and clickable final-response file references for Web", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, @@ -67,11 +67,20 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-client-ui-tool": "workspace:^", "@deepseek-ai/dsh-tool-present": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^" + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-util-workspace-path": "workspace:^", + "@deepseek-ai/dsh-api-workspace-files": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-fs-local": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^" }, "files": [ "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" - ] + ], + "dependencies": { + "@deepseek-ai/dsh-typert-protocol": "workspace:^" + } } diff --git a/packages/client/ui-deliverables/src/client/Deliverables.module.css b/packages/client/ui-deliverables/src/client/Deliverables.module.css index 97befd2946..5125edd381 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.module.css +++ b/packages/client/ui-deliverables/src/client/Deliverables.module.css @@ -1,14 +1,32 @@ -/** Immutable file deliveries at the end of a turn. */ -.root { display: flex; flex-direction: column; gap: 8px; min-width: 0; margin-top: 12px; } -.label { font-size: 12px; color: var(--dsw-alias-label-secondary); } -.presented { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); gap: 8px; } -.file { display: flex; align-items: center; gap: 12px; min-width: 0; padding: 14px; border: 0.5px solid var(--dsw-alias-border-l2); border-radius: 12px; background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); text-decoration: none; font: inherit; text-align: left; cursor: pointer; } -.file:hover { background: var(--dsw-alias-bg-layer-2); border-color: var(--dsw-alias-border-l3); } -.file:focus-visible { outline: 2px solid var(--dsw-alias-link); outline-offset: 2px; } -.fileIcon { flex: 0 0 auto; width: 24px; height: 24px; } -.details { display: flex; flex-direction: column; gap: 4px; min-width: 0; flex: 1; } -.fileName { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; font-weight: 500; } -.metadata { color: var(--dsw-alias-label-tertiary); font-size: 11px; } -.description { color: var(--dsw-alias-label-secondary); font-size: 12px; overflow-wrap: anywhere; } -.open { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 0 0 auto; color: var(--dsw-alias-link); font-size: 11px; } -.file:disabled { cursor: wait; opacity: 0.65; } +/** Delivery cards use one full-width row or a two-column, four-card summary. */ +.root { --deliverable-fill: var(--dsw-static-neutral-50); --deliverable-hover: var(--dsw-static-neutral-100); container-type: inline-size; display: flex; flex-direction: column; gap: 16px; min-width: 0; margin-top: 16px; } +:global(body[data-ds-dark-theme]) .root { --deliverable-fill: var(--dsw-static-neutral-850); --deliverable-hover: var(--dsw-static-neutral-800); } +.hostStatus { display: flex; align-items: center; gap: 8px; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); } +.presented { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px 12px; min-width: 0; } +.presented[data-single='true'] { grid-template-columns: minmax(0, 1fr); } +.file { position: relative; box-sizing: border-box; display: flex; align-items: center; gap: 10px; min-width: 0; height: 72px; padding: 12px; overflow: hidden; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 18px; background: var(--deliverable-fill); color: var(--dsw-alias-label-primary); transition: background-color 120ms ease; } +.file:hover { background: var(--deliverable-hover); } +.cardPreview { position: absolute; z-index: 1; inset: 0; width: 100%; padding: 0; border: 0; border-radius: inherit; background: transparent; cursor: pointer; } +.cardPreview:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--dsw-alias-brand-primary); } +.fileIcon { position: relative; z-index: 2; flex: 0 0 auto; display: grid; place-items: center; width: 48px; height: 48px; overflow: hidden; pointer-events: none; border: 0.5px solid var(--dsw-alias-border-l2); border-radius: 12px; background: var(--deliverable-fill); color: var(--dsw-alias-link); } +.fileBody { position: relative; z-index: 2; display: flex; flex: 1; align-items: center; justify-content: space-between; gap: 12px; min-width: 0; pointer-events: none; } +.details { display: flex; flex: 1; flex-direction: column; justify-content: center; gap: 2px; min-width: 0; } +.fileName { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; font-weight: 500; line-height: 22px; } +.description { overflow: hidden; color: var(--dsw-alias-label-tertiary); font-size: 12px; font-weight: 400; line-height: 18px; text-overflow: ellipsis; white-space: nowrap; } +.description[data-error='true'] { color: var(--dsw-alias-state-error-primary); } +.previewHint { display: none; } +.file:hover .secondaryText { display: none; } +.file:hover .previewHint { display: inline; } +.split { display: inline-flex; flex: none; align-items: stretch; box-sizing: border-box; height: 32px; overflow: hidden; pointer-events: auto; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 12px; background: var(--dsw-alias-button-floating-fill); } +.menuAnchor { align-self: stretch; } +.open, .chevron { display: inline-flex; align-items: center; justify-content: center; border: 0; background: none; color: var(--dsw-alias-label-primary); cursor: pointer; font-family: var(--dsw-font-family); } +.open { padding: 5px 10px; font-size: 14px; line-height: 22px; } +.chevron { padding: 5px 6px; border-left: 0.5px solid var(--dsw-alias-border-l3); color: var(--dsw-alias-label-secondary); } +.open:hover, .open:focus-visible, .chevron:hover:not(:disabled), .chevron:focus-visible { background: var(--dsw-alias-interactive-bg-hover); } +.chevron:disabled { color: var(--dsw-alias-label-dimmed); cursor: not-allowed; } +.menuActionIcon { display: block; width: 16px; height: 16px; } +.toggle { align-self: center; display: inline-flex; align-items: center; gap: 4px; min-width: 0; padding: 1px 11px; border: 0; border-radius: 8px; background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; font: inherit; font-size: 12px; line-height: 18px; } +.toggle:hover { background: var(--dsw-alias-interactive-bg-hover); } +.toggle svg { flex: none; width: 14px; height: 14px; } +@container (max-width: 620px) { .presented { grid-template-columns: minmax(0, 1fr); } } +@media (pointer: coarse) { .split { min-height: 44px; } .open, .chevron { min-width: 44px; } } diff --git a/packages/client/ui-deliverables/src/client/Deliverables.tsx b/packages/client/ui-deliverables/src/client/Deliverables.tsx index 2faf259ad4..dac421a577 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.tsx +++ b/packages/client/ui-deliverables/src/client/Deliverables.tsx @@ -1,20 +1,28 @@ /** Existing changed-file chips and explicitly declared files for a closing turn. */ +import { useEffect, useState } from 'react' import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' -import { LinkIcon, classifyLinkPath, IconRightUpOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' -import type { InjectFace, PropsLocale, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots' +import { Button, IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import type { GlobalStandardProps, InjectFace, PropsLocale, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { PresentedOpenController } from './present-open.ts' import { ProducedFiles } from './ProducedFiles.tsx' -import { basename, presentedForClosing, selectProducedFiles, type PresentedPath } from './turn-deliverables.ts' +import { presentedForClosing, selectProducedFiles, type PresentedPath } from './turn-deliverables.ts' import type { NS } from './locales.ts' import { presentedFileUrl } from '../presented.ts' +import { PresentedFileCard } from './PresentedFileCard.tsx' import css from './Deliverables.module.css' interface DeliverablesMatch { produced: readonly string[]; presented: readonly PresentedPath[] } +const COLLAPSED_PRESENTED_COUNT = 4 + /** Native-open callbacks and shared gesture status supplied by the plugin. */ export interface DeliverablesInjected { - hooks: { presentedOpen: ObservableSnapshot> } + hooks: { + presentedOpen: ObservableSnapshot> + presentedHost: ObservableSnapshot> + } + reloadPresentedHost: PresentedOpenController['loadHost'] openPresented: PresentedOpenController['open'] } @@ -34,31 +42,42 @@ export function selectDeliverables(owner: TurnTailOwnerProps): DeliverablesMatch * @param props - matched files, workspace opener, and localized copy. * @returns the closing turn's file rows. */ -export function Deliverables({ matched, openFile, t, sessionId, openPresented, usePresentedOpen }: Pick & { +export function Deliverables({ matched, openFile, t, sessionId, useSessions, openPresented, usePresentedOpen, usePresentedHost, reloadPresentedHost }: Pick & { matched: DeliverablesMatch -} & PropsLocale & Pick & InjectFace) { +} & PropsLocale & Pick & Pick & InjectFace) { + const [expanded, setExpanded] = useState(false) + const cwd = useSessions(state => state.byId[sessionId]?.cwd) const states = usePresentedOpen(value => value) + const host = usePresentedHost(value => value) + const collapsible = matched.presented.length > COLLAPSED_PRESENTED_COUNT + const presented = collapsible && !expanded + ? matched.presented.slice(0, COLLAPSED_PRESENTED_COUNT) + : matched.presented + useEffect(() => { + if (matched.presented.length > 0 && host === null) void reloadPresentedHost() + }, [matched.presented.length, host, reloadPresentedHost]) return <> {matched.produced.length > 0 && } {matched.presented.length > 0 &&
- {t('presented.label')} -
- {matched.presented.map((file) => { - const phase = states[presentedFileUrl(sessionId, file.seq, file.index)] - return })} + {host === 'error' &&
+ {t('presented.hostError')} + +
} + {host !== null && host !== 'error' && !host.available && {t('presented.unavailable')}} +
+ {presented.map(file => { openFile(file.path) }} + onAction={(action) => { void openPresented(sessionId, file.seq, file.index, action) }} />)}
+ {collapsible && }
} } diff --git a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx new file mode 100644 index 0000000000..ef848827e8 --- /dev/null +++ b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx @@ -0,0 +1,85 @@ +/** File identity and explicit default-app or file-manager actions for one delivery. */ +import { useRef, useState } from 'react' +import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' +import { + Menu, FileTypeIcon, fileExtension, IconRightUpOutline16, + IconChevronDownOutline14, IconFolderOpenOutline16, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { PresentedAction, PresentedHost } from '../presented.ts' +import type { PresentedOpenPhase } from './present-open.ts' +import { basename, type PresentedPath } from './turn-deliverables.ts' +import type { NS } from './locales.ts' +import css from './Deliverables.module.css' + +function cardDescription(description: string | undefined, fallback: string): string { + const trimmed = description?.replace(/\s*(?:\([^()]*\)|([^()]*))\s*$/u, '').trim() + return trimmed === undefined || trimmed === '' ? fallback : trimmed +} + +/** + * Render independent file actions without nesting buttons inside a clickable card. + * @param props - durable file metadata, Sidebar preview, Host capabilities, gesture status, and localized copy. + * @returns the file card and its anchored action menu. + */ +export function PresentedFileCard({ file, cwd, phase, host, onPreview, onAction, t }: { + file: PresentedPath + cwd: string | undefined + phase: PresentedOpenPhase | undefined + host: PresentedHost | null + onPreview: () => void + onAction: (action: PresentedAction) => void +} & PropsLocale) { + const [menuOpen, setMenuOpen] = useState(false) + const previewRef = useRef(null) + const pending = phase === 'opening' || phase === 'revealing' + const menuDisabled = pending || host === null || !host.available + if (menuDisabled && menuOpen) setMenuOpen(false) + const reveal = host?.fileManager ?? 'directory' + const act = (action: PresentedAction) => { + setMenuOpen(false) + previewRef.current?.focus() + onAction(action) + } + const name = basename(file.path) + const metadata = fileExtension(name).toUpperCase() || t('presented.file') + const status = phase === undefined + ? cardDescription(file.description, metadata) + : t(reveal === 'directory' && phase === 'revealed' ? 'presented.directoryOpened' + : reveal === 'directory' && phase === 'revealing' ? 'presented.directoryOpening' + : reveal === 'directory' && phase === 'revealError' ? 'presented.directoryError' : `presented.${phase}`) + return
+ + { setMenuOpen(false) }} + anchor={} + items={[ + { id: 'open', icon: , + label: t('presented.defaultApp') }, + { id: 'reveal', icon: , + label: t(`presented.${reveal}`) }, + ]} + onSelect={(id) => { act(id === 'reveal' ? 'reveal' : 'open') }} /> +
+
+ +} diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index 09156448b4..7ce723aa09 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -9,6 +9,7 @@ */ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-api-remotes/client' +import type {} from '@deepseek-ai/dsh-client-connection/client' import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-chat/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -41,6 +42,7 @@ export const inject = ['slots', 'locale', 'uiConversation', 'remote', 'remote.se export function apply(ctx: ClientContext): void { const opener = new PresentedOpenController() ctx.effect(() => () => opener.dispose()) + ctx.on('connection/reset', () => { opener.resetHost() }) ctx.uiConversation.events.register(deliverablesDefinition) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-deliverables: dictionaries') ctx.slots.inject( @@ -50,8 +52,9 @@ export function apply(ctx: ClientContext): void { select: selectDeliverables, locale: NS, inject: (): DeliverablesInjected => ({ - hooks: { presentedOpen: opener.state }, - openPresented: (sessionId, seq, index) => opener.open(sessionId, seq, index), + hooks: { presentedOpen: opener.state, presentedHost: opener.host }, + reloadPresentedHost: () => opener.loadHost(), + openPresented: (sessionId, seq, index, action) => opener.open(sessionId, seq, index, action), }), }, Deliverables), ) diff --git a/packages/client/ui-deliverables/src/client/locales.ts b/packages/client/ui-deliverables/src/client/locales.ts index d9b468e15c..1e4d78f8c1 100644 --- a/packages/client/ui-deliverables/src/client/locales.ts +++ b/packages/client/ui-deliverables/src/client/locales.ts @@ -5,8 +5,29 @@ export const NS = 'deliverables' /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { - 'presented.label': '交付文件', + 'presented.nativeUnavailable': '此文件没有可用的主机路径,请在侧边栏预览', + 'presented.revealError': '无法在文件管理器中显示,请重试', + 'presented.directoryError': '无法打开所在文件夹,请重试', + 'presented.directoryOpening': '正在打开所在文件夹…', + 'presented.directoryOpened': '已请求打开所在文件夹', + 'presented.revealed': '已请求在文件管理器中显示', + 'presented.revealing': '正在文件管理器中显示…', + 'presented.unavailable': '此主机没有可用的桌面,无法打开文件或文件夹', + 'presented.retry': '重试', + 'presented.hostError': '无法读取主机桌面信息', + 'presented.directory': '打开所在文件夹', + 'presented.explorer': '在文件资源管理器中显示', + 'presented.finder': '在 Finder 中显示', + 'presented.defaultApp': '用默认应用打开', + 'presented.more': '{name} 的更多文件操作', 'presented.action': '打开', + 'presented.preview': '在侧边栏预览', + 'presented.previewButton': '在侧边栏打开 {name}', + 'presented.previewCard': '在侧边栏预览 {name}', + 'presented.all': '全部 {count} 个文件', + 'presented.expandAria': '展开全部 {count} 个交付文件', + 'presented.collapse': '收起', + 'presented.collapseAria': '收起交付文件列表', 'presented.opening': '正在打开…', 'presented.opened': '已在默认程序中打开', 'presented.error': '打开失败,点击重试', @@ -18,7 +39,7 @@ export const zh = { 'row.stopped': '已中断', 'row.inspect': '查看调用', 'presented.open': '在默认程序中打开 {name}', - 'produced.label': '产物', + 'produced.label': '本轮文件改动', 'produced.moreOne': '+ 1 个文件', 'produced.more': '+ {count} 个文件', 'produced.open': '打开 {name}', @@ -26,8 +47,29 @@ export const zh = { /** English dictionary (same key set). */ export const en: Record = { - 'presented.label': 'Deliverables', + 'presented.nativeUnavailable': 'This file has no available Host path. Preview it in the sidebar.', + 'presented.revealError': 'Could not show in file manager. Try again.', + 'presented.directoryError': 'Could not open containing folder. Try again.', + 'presented.directoryOpening': 'Opening containing folder…', + 'presented.directoryOpened': 'Requested opening containing folder', + 'presented.revealed': 'Requested display in file manager', + 'presented.revealing': 'Showing in file manager…', + 'presented.unavailable': 'This Host has no desktop available to open files or folders', + 'presented.retry': 'Retry', + 'presented.hostError': 'Could not read the Host desktop information', + 'presented.directory': 'Open containing folder', + 'presented.explorer': 'Show in File Explorer', + 'presented.finder': 'Show in Finder', + 'presented.defaultApp': 'Open in default app', + 'presented.more': 'More file actions for {name}', 'presented.action': 'Open', + 'presented.preview': 'Preview in sidebar', + 'presented.previewButton': 'Open {name} in sidebar', + 'presented.previewCard': 'Preview {name} in sidebar', + 'presented.all': 'All {count} files', + 'presented.expandAria': 'Show all {count} delivered files', + 'presented.collapse': 'Collapse', + 'presented.collapseAria': 'Collapse delivered files', 'presented.opening': 'Opening…', 'presented.opened': 'Opened in default app', 'presented.error': 'Could not open. Click to retry.', @@ -39,7 +81,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/src/client/present-open.ts b/packages/client/ui-deliverables/src/client/present-open.ts index 5b49941a57..8e66b380c0 100644 --- a/packages/client/ui-deliverables/src/client/present-open.ts +++ b/packages/client/ui-deliverables/src/client/present-open.ts @@ -1,31 +1,37 @@ /** Shared native-open status for delivery cards and closing-message file mentions. */ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import { presentedFileUrl } from '../presented.ts' +import { presentedFileUrl, PRESENT_HOST_PATH, isPresentedHost, type PresentedAction, type PresentedHost } from '../presented.ts' /** State of the latest explicit open gesture for one saved file. */ -export type PresentedOpenPhase = 'opening' | 'opened' | 'error' +export type PresentedOpenPhase = 'opening' | 'opened' | 'revealing' | 'revealed' | 'error' | 'revealError' | 'nativeUnavailable' /** One browser plugin's file-open requests, cancelled when that plugin is disposed. */ export class PresentedOpenController { /** File action URLs key the state across Sessions, turns, and both clickable surfaces. */ readonly state = createSnapshotStore>({}) + /** Native destination metadata, or a retryable read failure. */ + readonly host = createSnapshotStore(null) + private loading: Promise | undefined + private metadata = new AbortController() private readonly lifetime = new AbortController() private readonly pending = new Set>() /** - * Open a declared workspace file once while a request for the same coordinates is pending. + * Open a declared file once while a request for the same coordinates is pending. * Failures remain visible on the card and a later gesture retries them. * @param sessionId - viewed Session, including a fork's own identity. * @param seq - durable delivery event sequence. * @param index - original file index within that event. + * @param action - default application open or file-manager reveal. * @returns after the Host acknowledges opening or the error state is published. */ - async open(sessionId: SessionId, seq: number, index: number): Promise { + async open(sessionId: SessionId, seq: number, index: number, action: PresentedAction = 'open'): Promise { const url = presentedFileUrl(sessionId, seq, index) - if (this.lifetime.signal.aborted || this.state.getSnapshot()[url] === 'opening') return - this.state.update((state) => { state[url] = 'opening' }) - const task = this.request(url) + const phase = this.state.getSnapshot()[url] + if (this.lifetime.signal.aborted || phase === 'opening' || phase === 'revealing') return + this.state.update((state) => { state[url] = action === 'open' ? 'opening' : 'revealing' }) + const task = this.request(url, action) this.pending.add(task) try { await task @@ -34,20 +40,63 @@ export class PresentedOpenController { } } + /** + * Read the serving desktop metadata, coalescing concurrent reads; a later call retries failure. + * @returns after metadata or a retryable error is published. + */ + async loadHost(): Promise { + if (this.lifetime.signal.aborted) return + if (this.loading !== undefined) return this.loading + this.host.set(null) + const task = this.readHost(AbortSignal.any([this.lifetime.signal, this.metadata.signal])) + this.loading = task + this.pending.add(task) + try { await task } + finally { + if (this.loading === task) this.loading = undefined + this.pending.delete(task) + } + } + + /** Invalidate desktop metadata on connection replacement; mounted cards request the new Host. */ + resetHost(): void { + const wasLoading = this.loading !== undefined + this.metadata.abort() + this.metadata = new AbortController() + this.loading = undefined + this.host.set(null) + if (wasLoading) void this.loadHost() + } + + private async readHost(signal: AbortSignal): Promise { + let host: PresentedHost | 'error' = 'error' + try { + const response = await fetch(PRESENT_HOST_PATH, { signal }) + if (response.ok) { + const value: unknown = await response.json() + if (isPresentedHost(value)) host = value + } + } catch { + host = 'error' + } + if (!signal.aborted) this.host.set(host) + } + /** Cancel outstanding requests and wait until no request can publish state. */ async dispose(): Promise { this.lifetime.abort() await Promise.all(this.pending) } - private async request(url: string): Promise { - let phase: PresentedOpenPhase = 'opened' + private async request(url: string, action: PresentedAction): Promise { + const failure = action === 'open' ? 'error' : 'revealError' + let phase: PresentedOpenPhase = action === 'open' ? 'opened' : 'revealed' try { - const response = await fetch(url, { method: 'POST', signal: this.lifetime.signal }) - if (!response.ok) phase = 'error' + const response = await fetch(action === 'open' ? url : `${url}&action=reveal`, { method: 'POST', signal: this.lifetime.signal }) + if (!response.ok) phase = response.status === 422 ? 'nativeUnavailable' : failure } catch { // Transport failures share the retryable card state with Host open failures. - phase = 'error' + phase = failure } if (!this.lifetime.signal.aborted) this.state.update((state) => { state[url] = phase }) } diff --git a/packages/client/ui-deliverables/src/index.ts b/packages/client/ui-deliverables/src/index.ts index 4239299e63..04e5e7a8dd 100644 --- a/packages/client/ui-deliverables/src/index.ts +++ b/packages/client/ui-deliverables/src/index.ts @@ -1,7 +1,7 @@ /** * Deliverables plugin, node half. Registers the response-format guidance that * lets the browser half recognize final-response file references and serves - * authenticated native opens of workspace files. The browser + * authenticated native opens of declared files. The browser * half ships via exports["./client"], discovered through the package.json * dsh.client declaration. */ @@ -10,8 +10,8 @@ import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-system-prompt' import { registerPresentOpen } from './present-open.ts' -/** Services required for file-reference guidance and authenticated native opens of workspace files. */ -export const inject = ['systemPrompt', 'connection', 'sessionQuery', 'sessionController'] +/** Services required for file-reference guidance and authenticated native opens of declared files. */ +export const inject = ['systemPrompt', 'connection', 'sessionQuery', 'sessionController', 'workspaceFiles', 'fs', 'sandboxPolicy'] /** Stable final-response guidance owned by the matching renderer. */ const FILE_REFERENCE_PROMPT = 'When you successfully create or modify files, mention the primary outputs in your final response. ' diff --git a/packages/client/ui-deliverables/src/present-open.ts b/packages/client/ui-deliverables/src/present-open.ts index beac77657a..9589ef0006 100644 --- a/packages/client/ui-deliverables/src/present-open.ts +++ b/packages/client/ui-deliverables/src/present-open.ts @@ -1,18 +1,25 @@ -/** Open declared source files inside the viewed Session's workspace. */ -import { realpath, stat } from 'node:fs/promises' -import { isAbsolute, relative, resolve, sep } from 'node:path' +/** Open declared source files verified by the viewed Session's filesystem. */ import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-api-session-controller' +import type {} from '@deepseek-ai/dsh-api-workspace-files' +import type {} from '@deepseek-ai/dsh-fs' +import type {} from '@deepseek-ai/dsh-sandbox-policy' +import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' import type {} from '@deepseek-ai/dsh-client-connection' import type {} from '@deepseek-ai/dsh-session-query' import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session' -import { isPresentedData, isPresentedFile, PRESENT_OPEN_PATH } from './presented.ts' +import { isPresentedData, isPresentedFile, PRESENT_OPEN_PATH, PRESENT_HOST_PATH, type PresentedHost } from './presented.ts' /** * Register native opening inside Connection's authentication fence. * @param ctx - Session lookup, native opener, and route lifetime. */ export function registerPresentOpen(ctx: Context): void { + ctx.connection.fetch.register({ + path: PRESENT_HOST_PATH, methods: ['GET'], requestBody: 'buffered', + fetch: () => Promise.resolve(Response.json(ctx.sessionController.workspaceDesktop() satisfies PresentedHost, + { headers: { 'cache-control': 'no-store' } })), + }) const lifetime = new AbortController() const pending = new Set>() ctx.effect(() => async () => { @@ -36,6 +43,8 @@ export function registerPresentOpen(ctx: Context): void { async function handlePresentOpen(ctx: Context, request: Request): Promise { const query = new URL(request.url).searchParams + const action = query.get('action') ?? 'open' + if (action !== 'open' && action !== 'reveal') return new Response('Invalid file action.', { status: 400 }) const id = query.get('sessionId') const seq = query.get('seq') const index = query.get('index') @@ -45,27 +54,32 @@ async function handlePresentOpen(ctx: Context, request: Request): Promise + return typeof host.name === 'string' && typeof host.available === 'boolean' + && (host.fileManager === null || host.fileManager === 'finder' + || host.fileManager === 'explorer' || host.fileManager === 'directory') +} + /** * Validate a file declaration read from a Session log. * @param value - decoded durable data. diff --git a/packages/client/ui-deliverables/tests/present-open.client.spec.ts b/packages/client/ui-deliverables/tests/present-open.client.spec.ts index b201895445..bf990969ab 100644 --- a/packages/client/ui-deliverables/tests/present-open.client.spec.ts +++ b/packages/client/ui-deliverables/tests/present-open.client.spec.ts @@ -61,3 +61,103 @@ it('awaits cancellation and prevents late state publication or new requests afte await controller.open(id, 2, 1) expect(fetcher).toHaveBeenCalledOnce() }) + + +it('shares pending state across open and reveal and retries the selected action', async () => { + const reply = Promise.withResolvers() + const fetcher = vi.fn().mockReturnValueOnce(reply.promise).mockResolvedValue(new Response(null, { status: 204 })) + vi.stubGlobal('fetch', fetcher) + const controller = new PresentedOpenController() + const revealing = controller.open(id, 2, 1, 'reveal') + await controller.open(id, 2, 1) + expect(controller.state.getSnapshot()[url]).toBe('revealing') + expect(fetcher).toHaveBeenCalledOnce() + expect(fetcher.mock.calls[0]?.[0]).toBe(`${url}&action=reveal`) + reply.resolve(new Response(null, { status: 500 })) + await revealing + expect(controller.state.getSnapshot()[url]).toBe('revealError') + await controller.open(id, 2, 1, 'reveal') + expect(controller.state.getSnapshot()[url]).toBe('revealed') + await controller.dispose() +}) + +it.each([null, {}, { name: 'host', available: 'yes', fileManager: 'finder' }, + { name: 'host', available: true, fileManager: 'unknown' }, 'invalid json', 'http', 'network', +])('makes invalid Host metadata retryable: %j', async (value) => { + const host = { name: 'linux-host', available: true, fileManager: 'directory' } + const fetcher = vi.fn() + if (value === 'network') fetcher.mockRejectedValueOnce(new Error('offline')) + else if (value === 'http') fetcher.mockResolvedValueOnce(new Response(null, { status: 500 })) + else if (value === 'invalid json') fetcher.mockResolvedValueOnce(new Response('bad JSON')) + else fetcher.mockResolvedValueOnce(Response.json(value)) + fetcher.mockResolvedValueOnce(Response.json(host)) + vi.stubGlobal('fetch', fetcher) + const controller = new PresentedOpenController() + await controller.loadHost() + expect(controller.host.getSnapshot()).toBe('error') + await controller.loadHost() + expect(controller.host.getSnapshot()).toEqual(host) + await controller.dispose() + await controller.loadHost() + expect(fetcher).toHaveBeenCalledTimes(2) +}) + +it('coalesces metadata reads and suppresses their publication after disposal', async () => { + const reply = Promise.withResolvers() + const fetcher = vi.fn().mockReturnValue(reply.promise) + vi.stubGlobal('fetch', fetcher) + const controller = new PresentedOpenController() + const first = controller.loadHost() + const second = controller.loadHost() + expect(fetcher).toHaveBeenCalledOnce() + const disposal = controller.dispose() + expect((fetcher.mock.calls[0]?.[1] as RequestInit).signal?.aborted).toBe(true) + reply.resolve(Response.json({ name: 'host', available: false, fileManager: null })) + await Promise.all([first, second, disposal]) + expect(controller.host.getSnapshot()).toBeNull() +}) + + +it('invalidates cached desktop metadata without eagerly fetching an unused Host', async () => { + const fetcher = vi.fn().mockResolvedValue(Response.json({ name: 'old', available: false, fileManager: null })) + vi.stubGlobal('fetch', fetcher) + const controller = new PresentedOpenController() + await controller.loadHost() + controller.resetHost() + expect(controller.host.getSnapshot()).toBeNull() + expect(fetcher).toHaveBeenCalledOnce() + fetcher.mockResolvedValue(Response.json({ name: 'new', available: true, fileManager: 'finder' })) + await controller.loadHost() + expect(controller.host.getSnapshot()).toMatchObject({ name: 'new', available: true }) + await controller.dispose() +}) + +it('discards a replaced Host response and keeps the new metadata request coalesced', async () => { + const oldReply = Promise.withResolvers() + const newReply = Promise.withResolvers() + const fetcher = vi.fn().mockReturnValueOnce(oldReply.promise).mockReturnValue(newReply.promise) + vi.stubGlobal('fetch', fetcher) + const controller = new PresentedOpenController() + const oldLoad = controller.loadHost() + controller.resetHost() + expect((fetcher.mock.calls[0]?.[1] as RequestInit).signal?.aborted).toBe(true) + const newLoad = controller.loadHost() + oldReply.resolve(Response.json({ name: 'old', available: false, fileManager: null })) + await oldLoad + expect(controller.host.getSnapshot()).toBeNull() + const coalesced = controller.loadHost() + expect(fetcher).toHaveBeenCalledTimes(2) + newReply.resolve(Response.json({ name: 'new', available: true, fileManager: 'finder' })) + await Promise.all([newLoad, coalesced]) + expect(controller.host.getSnapshot()).toMatchObject({ name: 'new' }) + await controller.dispose() +}) + + +it.each(['open', 'reveal'] as const)('reports an unavailable Host path for %s while retaining the declaration', async (action) => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 422 }))) + const controller = new PresentedOpenController() + await controller.open(id, 2, 1, action) + expect(controller.state.getSnapshot()[url]).toBe('nativeUnavailable') + await controller.dispose() +}) diff --git a/packages/client/ui-deliverables/tests/present-open.host.spec.ts b/packages/client/ui-deliverables/tests/present-open.host.spec.ts index b9319587b6..1282a47b85 100644 --- a/packages/client/ui-deliverables/tests/present-open.host.spec.ts +++ b/packages/client/ui-deliverables/tests/present-open.host.spec.ts @@ -2,6 +2,8 @@ import { mkdtemp, rm, readFile, writeFile, mkdir, realpath, symlink, unlink } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' +import { WorkspaceFiles } from '@deepseek-ai/dsh-api-workspace-files' import { Context } from '@deepseek-ai/cordis' import { HostConnectionService } from '@deepseek-ai/dsh-client-connection' import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser-auth.ts' @@ -30,22 +32,29 @@ async function fixture() { const ctx = new Context() cleanups.push(() => ctx.fiber.dispose()) const session: { cwd?: string } = { cwd } + await ctx.plugin(LocalFileSystem, { cwd }) + ctx.provide('sandboxPolicy', { workspaceRoot: cwd } as never) + await ctx.plugin({ + inject: ['fs', 'sandboxPolicy'], + apply: (scope) => { new WorkspaceFiles(scope, { maxBytes: 1024, maxFileBytes: 1024, maxLines: 100, maxEntries: 100 }) }, + }) + const resolveAgent = vi.fn(() => { throw new Error('Agent activation is unavailable') }) const readEvent = vi.fn(async (request: SessionEventReadRequest) => { if (request.sessionId !== 'owner') throw new SessionQueryError('missing', 'SESSION_QUERY_SESSION_NOT_FOUND') if (request.seq !== 7) throw new SessionQueryError('missing', 'SESSION_QUERY_EVENT_NOT_FOUND') return { session, target: { type: 'deliverables/presented', data: { turn: 1, callId: 'present-call', files: [file] } } as SessionEvent } }) ctx.provide('sessionQuery', { readEvent } as never) - const opener = vi.fn(async (_request: { path: string }, _signal: AbortSignal) => ({ opened: true as const })) - ctx.provide('sessionController', { openWorkspacePath: opener } as never) + const opener = vi.fn(async (_request: { path: string; action?: 'reveal' }, _signal: AbortSignal) => ({ opened: true as const })) + ctx.provide('sessionController', { resolveAgent, openWorkspacePath: opener, workspaceDesktop: () => ({ name: 'desktop', available: true, fileManager: 'finder' }) } as never) const connection = new HostConnectionService(ctx, [], {} as BrowserAuth) - const fiber = ctx.plugin({ inject: ['connection', 'sessionQuery', 'sessionController'], apply: registerPresentOpen }) + const fiber = ctx.plugin({ inject: ['connection', 'sessionQuery', 'sessionController', 'workspaceFiles', 'fs', 'sandboxPolicy'], apply: registerPresentOpen }) await fiber const handler = connection.createSharedFetchHandler('/api') const open = (query = '?sessionId=owner&seq=7&index=0', signal?: AbortSignal) => handler.fetch(new Request( `http://localhost${PRESENT_OPEN_PATH}${query}`, { method: 'POST', signal: signal ?? null }, )) - return { root, cwd, ctx, fiber, file, session, readEvent, open, opener, handler } + return { root, cwd, ctx, fiber, file, session, readEvent, open, opener, handler, resolveAgent } } describe('Presented workspace file native open route', () => { @@ -75,7 +84,8 @@ describe('Presented workspace file native open route', () => { const fork = join(root, 'fork') await mkdir(fork) await writeFile(join(fork, file.path), 'child source') - readEvent.mockResolvedValueOnce({ session: { ...session, cwd: fork }, target: { type: 'deliverables/presented', data: { turn: 1, callId: 'inherited', files: [file] } } as SessionEvent }) + session.cwd = fork + readEvent.mockResolvedValueOnce({ session, target: { type: 'deliverables/presented', data: { turn: 1, callId: 'inherited', files: [file] } } as SessionEvent }) expect((await open('?sessionId=fork&seq=7&index=0')).status).toBe(204) expect(opener.mock.lastCall?.[0].path).toBe(await realpath(join(fork, file.path))) }) @@ -109,30 +119,29 @@ describe('Presented workspace file native open route', () => { expect(opener).not.toHaveBeenCalled() }) - it('reports removed files, directories, and absent workspaces without launching', async () => { - const { cwd, file, session, open, opener } = await fixture() + it('reports removed files and directories without launching', async () => { + const { cwd, file, open, opener } = await fixture() await unlink(join(cwd, file.path)) expect((await open()).status).toBe(404) file.path = '.' expect((await open()).status).toBe(404) - delete session.cwd - expect((await open()).status).toBe(404) expect(opener).not.toHaveBeenCalled() }) - it('refuses traversal and a source replaced by a symlink outside the workspace', async () => { + it('opens external regular files through absolute and relative paths but refuses final symlinks', async () => { const { root, cwd, file, open, opener } = await fixture() const outside = join(root, 'outside.txt') await writeFile(outside, 'outside') const source = join(cwd, file.path) await unlink(source) await symlink(outside, source) - expect((await open()).status).toBe(403) - file.path = '../outside.txt' - expect((await open()).status).toBe(403) - file.path = outside - expect((await open()).status).toBe(403) + expect((await open()).status).toBe(404) expect(opener).not.toHaveBeenCalled() + for (const path of ['../outside.txt', outside]) { + file.path = path + expect((await open()).status).toBe(204) + expect(opener.mock.lastCall?.[0].path).toBe(await realpath(outside)) + } }) it('reports query and launcher failures without leaking Host paths and allows retry', async () => { @@ -176,3 +185,51 @@ describe('Presented workspace file native open route', () => { await Promise.all([request, disposal]) }) }) + + +it('reports the serving desktop and reveals only an authorized declared source', async () => { + const { cwd, file, open, opener, handler } = await fixture() + const info = await handler.fetch(new Request('http://localhost/api/present.host')) + expect(await info.json()).toEqual({ name: 'desktop', available: true, fileManager: 'finder' }) + expect((await open('?sessionId=owner&seq=7&index=0&action=reveal')).status).toBe(204) + expect(opener).toHaveBeenCalledWith({ path: await realpath(join(cwd, file.path)), action: 'reveal' }, expect.any(AbortSignal)) + expect((await open('?sessionId=owner&seq=7&index=0&action=delete')).status).toBe(400) + file.path = '..' + expect((await open('?sessionId=owner&seq=7&index=0&action=reveal')).status).toBe(404) + expect(opener).toHaveBeenCalledOnce() +}) + +it('refuses native actions when the configured Host desktop is unavailable', async () => { + const { ctx, open, opener, handler } = await fixture() + vi.spyOn(ctx.sessionController, 'workspaceDesktop').mockReturnValue({ name: 'desktop', available: false, fileManager: 'finder' }) + expect(await (await handler.fetch(new Request('http://localhost/api/present.host'))).json()).toMatchObject({ available: false }) + for (const action of ['open', 'reveal']) { + expect((await open(`?sessionId=owner&seq=7&index=0&action=${action}`)).status).toBe(409) + } + expect(opener).not.toHaveBeenCalled() +}) + + +it('refuses native opening without a matching Host mapping even when a same-name Host file exists', async () => { + const { ctx, open, opener } = await fixture() + const mapping = vi.spyOn(ctx.fs, 'processPathFromHostPath').mockReturnValue(undefined) + expect((await open()).status).toBe(422) + mapping.mockReturnValue('/another-filesystem/file') + expect((await open()).status).toBe(422) + expect(opener).not.toHaveBeenCalled() +}) + +it('opens a viewed child Session without activating an Agent', async () => { + const { session, readEvent, open, opener, resolveAgent } = await fixture() + readEvent.mockResolvedValueOnce({ session, target: { type: 'deliverables/presented', data: { turn: 1, callId: 'child', files: [{ path: '日记模板.docx' }] } } as SessionEvent }) + expect((await open('?sessionId=child&seq=7&index=0')).status).toBe(204) + expect(opener).toHaveBeenCalledOnce() + expect(resolveAgent).not.toHaveBeenCalled() +}) + +it('uses the deployment workspace root when the viewed Session has no cwd', async () => { + const { session, open, cwd, file, opener } = await fixture() + delete session.cwd + expect((await open()).status).toBe(204) + expect(opener.mock.lastCall?.[0].path).toBe(await realpath(join(cwd, file.path))) +}) diff --git a/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx b/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx new file mode 100644 index 0000000000..28207b5a29 --- /dev/null +++ b/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx @@ -0,0 +1,166 @@ +// @vitest-environment jsdom +/** Explicit file actions preserve their destination, availability, and independent failure state. */ +import { cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { PresentedFileCard } from '../src/client/PresentedFileCard.tsx' +import { en, zh } from '../src/client/locales.ts' + +afterEach(cleanup) +const props = () => ({ + cwd: undefined, + file: { path: 'out/report.pdf', description: 'Final report', seq: 4, index: 1 }, + host: { name: 'remote-desktop', available: true, fileManager: 'finder' as const }, + phase: undefined, + onPreview: vi.fn(), + onAction: vi.fn(), + t: makeTranslate(en), +}) + +it.each([ + ['finder', 'Show in Finder'], ['explorer', 'Show in File Explorer'], ['directory', 'Open containing folder'], +] as const)('uses the Host %s action and closes the menu after selection', (fileManager, label) => { + const p = props() + const view = render() + fireEvent.click(view.getByRole('button', { name: 'More file actions for out/report.pdf' })) + fireEvent.click(view.getByRole('menuitem', { name: new RegExp(label) })) + expect(p.onAction).toHaveBeenCalledWith('reveal') + expect(view.queryByRole('menu')).toBeNull() + fireEvent.click(view.getByRole('button', { name: 'More file actions for out/report.pdf' })) + fireEvent.click(view.getByRole('menuitem', { name: /Open in default app/ })) + expect(p.onAction).toHaveBeenLastCalledWith('open') + expect(p.onAction).toHaveBeenCalledTimes(2) +}) + +it('dismisses the menu with Escape or an outside click without launching anything', () => { + const p = props() + const view = render() + const trigger = view.getByRole('button', { name: 'More file actions for out/report.pdf' }) + fireEvent.click(trigger) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(view.queryByRole('menu')).toBeNull() + fireEvent.click(trigger) + fireEvent.pointerDown(document.body) + expect(view.queryByRole('menu')).toBeNull() + expect(p.onAction).not.toHaveBeenCalled() +}) + +it.each(['opening', 'revealing'] as const)('keeps sidebar previews available while the native action is %s', (phase) => { + const p = props() + const view = render() + expect((view.getByRole('button', { name: 'More file actions for out/report.pdf' }) as HTMLButtonElement).disabled).toBe(true) + fireEvent.click(view.getByRole('button', { name: 'Preview out/report.pdf in sidebar' })) + fireEvent.click(view.getByRole('button', { name: 'Open out/report.pdf in sidebar' })) + expect(p.onPreview).toHaveBeenCalledTimes(2) + expect(p.onAction).not.toHaveBeenCalled() +}) + +it('keeps the native menu disabled until a desktop is available', () => { + const p = props() + const view = render() + expect((view.getByRole('button', { name: 'More file actions for out/report.pdf' }) as HTMLButtonElement).disabled).toBe(true) + expect((view.getByRole('button', { name: 'Open out/report.pdf in sidebar' }) as HTMLButtonElement).disabled).toBe(false) + view.rerender() + expect((view.getByRole('button', { name: 'More file actions for out/report.pdf' }) as HTMLButtonElement).disabled).toBe(true) +}) + +it('opens the right sidebar from either the card or its primary button', () => { + const p = props() + const view = render() + fireEvent.click(view.getByRole('button', { name: 'Preview out/report.pdf in sidebar' })) + fireEvent.click(view.getByRole('button', { name: 'Open out/report.pdf in sidebar' })) + expect(p.onPreview).toHaveBeenCalledTimes(2) + expect(p.onAction).not.toHaveBeenCalled() +}) + +it('localizes reveal failures and accurately reports a directory-only action', () => { + const p = props() + const view = render() + expect(view.getByText(zh['presented.revealError'])).toBeTruthy() + view.rerender() + expect(view.getByText(en['presented.directoryOpened'])).toBeTruthy() + view.rerender() + expect(view.getByText(en['presented.revealed'])).toBeTruthy() +}) + + +it('supports keyboard selection and returns focus to the trigger on Escape', () => { + const view = render() + const trigger = view.getByRole('button', { name: 'More file actions for out/report.pdf' }) + fireEvent.click(trigger) + const items = view.getAllByRole('menuitem') + expect(document.activeElement).toBe(items[0]) + fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' }) + expect(document.activeElement).toBe(items[1]) + fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' }) + expect(document.activeElement).toBe(items[0]) + fireEvent.keyDown(document.activeElement!, { key: 'End' }) + expect(document.activeElement).toBe(items[1]) + fireEvent.keyDown(document.activeElement!, { key: 'ArrowUp' }) + expect(document.activeElement).toBe(items[0]) + fireEvent.keyDown(document.activeElement!, { key: 'Home' }) + expect(document.activeElement).toBe(items[0]) + fireEvent.keyDown(document.activeElement!, { key: 'Escape' }) + expect(document.activeElement).toBe(trigger) +}) + + +it('shows the basename while retaining the full location for hover and actions', () => { + const p = props() + const path = '/work/reports/result.pdf' + const view = render() + expect(view.getByTitle(path)).toBeTruthy() + expect(view.getByText('result.pdf')).toBeTruthy() + fireEvent.click(view.getByRole('button', { name: `More file actions for ${path}` })) + fireEvent.click(view.getByRole('menuitem', { name: 'Open in default app' })) + expect(p.onAction).toHaveBeenCalledWith('open') + view.rerender() + expect(view.getByTitle('/work/out/report.pdf')).toBeTruthy() + expect(view.getByText('report.pdf')).toBeTruthy() +}) + +it.each([ + ['Quarterly summary (.pdf)', 'Quarterly summary'], + ['季度总结(PDF)', '季度总结'], +] as const)('omits a trailing parenthesized file suffix from %s', (description, expected) => { + const p = props() + const view = render() + expect(view.getByText(expected)).toBeTruthy() + expect(view.queryByText(description)).toBeNull() +}) + + +it('does not reopen a menu after a shared native request settles', () => { + const p = props() + const view = render() + fireEvent.click(view.getByRole('button', { name: 'More file actions for out/report.pdf' })) + expect(view.getByRole('menu')).toBeTruthy() + view.rerender() + expect(view.queryByRole('menu')).toBeNull() + view.rerender() + expect(view.queryByRole('menu')).toBeNull() +}) + +it('keeps focus on the available preview button after selecting a native action', () => { + const p = props() + const view = render() + fireEvent.click(view.getByRole('button', { name: 'More file actions for out/report.pdf' })) + const item = view.getByRole('menuitem', { name: 'Show in Finder' }) + item.focus() + fireEvent.click(item) + view.rerender() + const preview = view.getByRole('button', { name: 'Open out/report.pdf in sidebar' }) + expect(document.activeElement).toBe(preview) + view.rerender() + expect(document.activeElement).toBe(preview) +}) + +it.each([en, zh])('distinguishes directory-only progress and errors in each locale', (dictionary) => { + const p = { ...props(), t: makeTranslate(dictionary) } + const view = render() + expect(view.getByText(dictionary['presented.revealing'])).toBeTruthy() + view.rerender() + expect(view.getByText(dictionary['presented.directoryOpening'])).toBeTruthy() + view.rerender() + expect(view.getByText(dictionary['presented.directoryError'])).toBeTruthy() +}) 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..6a70186f27 100644 --- a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx @@ -8,7 +8,7 @@ import { Context } from '@deepseek-ai/cordis' import { cleanup, fireEvent, render, within } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { SessionLiveEventEntry } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionLiveEventEntry, SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import { ConversationNodeAssembler, UiConversation, } from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -34,7 +34,13 @@ import { SessionId } from '@deepseek-ai/dsh-session/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' function openProps(controller = new PresentedOpenController()) { + controller.host.set({ name: 'desktop', available: true, fileManager: 'finder' }) + const sessions: SessionListState = { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined } return { + useSessions: (select: (state: SessionListState) => T): T => select(sessions), + reloadPresentedHost: vi.fn(() => controller.loadHost()), + usePresentedHost: (select: (state: ReturnType) => T): T => + select(controller.host.getSnapshot()), openPresented: vi.fn((...args: Parameters) => controller.open(...args)), usePresentedOpen: (select: (state: ReturnType) => T): T => select(controller.state.getSnapshot()), @@ -426,7 +432,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) @@ -549,12 +555,24 @@ describe('plugin registration', () => { service?.forClosing(delivered, SessionId('child-session'))?.resolve('report.docx')?.open() expect(fetcher).toHaveBeenCalledWith('/api/present.open?sessionId=child-session&seq=2&index=0', { method: 'POST', signal: expect.any(AbortSignal) as AbortSignal }) const face = entry!.inject!(SessionId('child-session') as never) as unknown as DeliverablesInjected + fetcher.mockResolvedValueOnce(Response.json({ name: 'desktop', available: true, fileManager: 'finder' })) + await face.reloadPresentedHost() + expect(face.hooks.presentedHost.getSnapshot()).toMatchObject({ name: 'desktop' }) + ctx.emit('connection/reset') + expect(face.hooks.presentedHost.getSnapshot()).toBeNull() await face.openPresented(SessionId('child-session'), 2, 0) expect(face.hooks.presentedOpen.getSnapshot()['/api/present.open?sessionId=child-session&seq=2&index=0']).toBe('opened') // A turn that produced nothing yields no vocabulary at all. expect(service?.forClosing(tailOwner(undefined, 2), SessionId('viewed-session'))).toBeUndefined() + fetcher.mockResolvedValueOnce(Response.json({ name: 'last-host', available: true, fileManager: 'finder' })) + await face.reloadPresentedHost() await fiber.dispose() + const reset = vi.fn() + const unsubscribe = face.hooks.presentedHost.subscribe(reset) + ctx.emit('connection/reset') + expect(reset).not.toHaveBeenCalled() + unsubscribe() expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(0) expect(ctx.slots.entries('tool.call.toolview')).toHaveLength(0) // Fiber teardown retracts the service: the consumer's ctx.get sees the off state. @@ -581,21 +599,34 @@ describe('presented files', () => { expect(selectDeliverables(tailOwner(deliverablesOf(value, 2), 9))).toBeNull() }) - it('uses the viewed fork Session in every open action and retains all delivered files', () => { + it('uses the viewed fork Session in every open action and expands all delivered files', () => { const value = assembler([ at(1, 'turn/start', { turn: 1 }), at(2, 'deliverables/presented', { turn: 1, callId: 'nested', files: Array.from({ length: 8 }, (_, i) => file(`report-${i}.docx`)) }), ]) - const owner = tailOwner(deliverablesOf(value), 3) + const preview = vi.fn() + const owner = tailOwner(deliverablesOf(value), 3, preview) const matched = selectDeliverables(owner)! const props = openProps() props.openPresented.mockResolvedValue(undefined) const view = render() - expect(view.getAllByRole('button')).toHaveLength(8) + expect(view.container.querySelectorAll('[data-presented-file]')).toHaveLength(4) + const expand = view.getByRole('button', { name: 'Show all 8 delivered files' }) + expect(expand.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(expand) + expect(view.container.querySelectorAll('[data-presented-file]')).toHaveLength(8) + expect(view.getByRole('button', { name: 'Collapse delivered files' }).getAttribute('aria-expanded')).toBe('true') 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() + fireEvent.click(view.getByRole('button', { name: 'Preview report-0.docx in sidebar' })) + fireEvent.click(view.getByRole('button', { name: 'Open report-0.docx in sidebar' })) + expect(preview).toHaveBeenCalledTimes(2) + expect(preview).toHaveBeenLastCalledWith('report-0.docx') + fireEvent.click(view.getByRole('button', { name: 'More file actions for report-0.docx' })) + fireEvent.click(view.getByRole('menuitem', { name: 'Open in default app' })) + expect(props.openPresented).toHaveBeenCalledWith('child-session', 2, 0, 'open') + fireEvent.click(view.getByRole('button', { name: 'Collapse delivered files' })) + expect(view.container.querySelectorAll('[data-presented-file]')).toHaveLength(4) + expect(view.queryByText('Files changed')).toBeNull() }) }) @@ -617,19 +648,41 @@ 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() }) -it('shows file metadata and descriptions without hiding extensionless deliveries', () => { +it('shows descriptions and falls back to file metadata without hiding extensionless deliveries', () => { const view = render( {}} sessionId={SessionId('session')} t={makeTranslate(en)} />) expect(view.getByText('Quarterly summary')).toBeTruthy() - expect(view.getByText('TXT')).toBeTruthy() expect(view.getByText('File')).toBeTruthy() - expect(view.getByRole('button', { name: 'Open out/report.txt in default app' }).getAttribute('title')).toBe('Open out/report.txt in default app') + expect(view.getByTitle('out/report.txt')).toBeTruthy() + expect(view.getByText('report.txt')).toBeTruthy() +}) + +it('distinguishes PDF, Word, Markdown, and code files with full-size decorative card icons', () => { + const paths = ['report.pdf', 'report.docx', 'README.md', 'index.tsx'] + const view = render( ({ path, seq: 2, index })), + }} openFile={() => {}} sessionId={SessionId('session')} t={makeTranslate(en)} />) + const icons = [...view.container.querySelectorAll('[data-presented-file]')].map((card) => { + const icon = card.querySelector('svg')! + expect(icon.getAttribute('aria-hidden')).toBe('true') + expect(icon.getAttribute('width')).toBe('28') + return icon.innerHTML + }) + expect(new Set(icons).size).toBe(paths.length) +}) + +it('lets one delivered file span the complete row without an expansion control', () => { + const view = render( {}} sessionId={SessionId('session')} t={makeTranslate(en)} />) + expect(view.container.querySelector('[data-presented-files-row]')?.getAttribute('data-single')).toBe('true') + expect(view.queryByRole('button', { name: /delivered files/ })).toBeNull() }) @@ -640,6 +693,34 @@ it.each(['opening', 'opened', 'error'] as const)('shows the %s state and permits const view = render( {}} sessionId={SessionId('session')} t={makeTranslate(en)} />) - expect(view.getByRole('status').textContent).toBe(en[`presented.${phase}`]) - expect((view.getByRole('button') as HTMLButtonElement).disabled).toBe(phase === 'opening') + expect(view.getByText(en[`presented.${phase}`])).toBeTruthy() + expect((view.getByRole('button', { name: 'More file actions for report.txt' }) as HTMLButtonElement).disabled).toBe(phase === 'opening') +}) + + +it('explains a missing desktop and retries failed Host metadata', () => { + const controller = new PresentedOpenController() + const props = openProps(controller) + const matched = { produced: [], presented: [{ path: 'file.txt', seq: 2, index: 0 }] } + controller.host.set('error') + const view = render( {}} sessionId={SessionId('session')} t={makeTranslate(en)} />) + props.reloadPresentedHost.mockResolvedValue(undefined) + fireEvent.click(view.getByRole('button', { name: 'Retry' })) + expect(props.reloadPresentedHost).toHaveBeenCalledOnce() + controller.host.set({ name: 'server', available: false, fileManager: null }) + view.rerender( {}} sessionId={SessionId('session')} t={makeTranslate(en)} />) + expect(view.getByText(en['presented.unavailable'])).toBeTruthy() +}) + + +it('loads desktop information only when delivery cards appear', () => { + const controller = new PresentedOpenController() + const props = openProps(controller) + controller.host.set(null) + props.reloadPresentedHost.mockResolvedValue(undefined) + const shared = { ...props, openFile: () => {}, sessionId: SessionId('session'), t: makeTranslate(en) } + const view = render() + expect(props.reloadPresentedHost).not.toHaveBeenCalled() + view.rerender() + expect(props.reloadPresentedHost).toHaveBeenCalledOnce() }) diff --git a/packages/client/ui-deliverables/tests/prompt.host.spec.ts b/packages/client/ui-deliverables/tests/prompt.host.spec.ts index 8f342099fd..b56bfa3b8e 100644 --- a/packages/client/ui-deliverables/tests/prompt.host.spec.ts +++ b/packages/client/ui-deliverables/tests/prompt.host.spec.ts @@ -19,6 +19,9 @@ describe('ui-deliverables node plugin', () => { ctx.provide('connection', { fetch: { register: () => () => {} } } as never) ctx.provide('sessionQuery', {} as never) ctx.provide('sessionController', {} as never) + ctx.provide('workspaceFiles', {} as never) + ctx.provide('fs', {} as never) + ctx.provide('sandboxPolicy', {} as never) const mounted = ctx.plugin({ apply, inject }) await mounted.await() diff --git a/packages/client/ui-deliverables/tsconfig.client.json b/packages/client/ui-deliverables/tsconfig.client.json index 3b8f95c87c..8206c090cb 100644 --- a/packages/client/ui-deliverables/tsconfig.client.json +++ b/packages/client/ui-deliverables/tsconfig.client.json @@ -52,6 +52,9 @@ }, { "path": "../../fs/tool-present" + }, + { + "path": "../../util/workspace-path" } ] } diff --git a/packages/client/ui-deliverables/tsconfig.host.json b/packages/client/ui-deliverables/tsconfig.host.json index 0123290583..289e7df535 100644 --- a/packages/client/ui-deliverables/tsconfig.host.json +++ b/packages/client/ui-deliverables/tsconfig.host.json @@ -34,6 +34,18 @@ }, { "path": "../../api/session-controller/tsconfig.host.json" + }, + { + "path": "../../api/workspace-files/tsconfig.host.json" + }, + { + "path": "../../fs/fs" + }, + { + "path": "../../typert/protocol" + }, + { + "path": "../../sandbox/sandbox-policy" } ] } diff --git a/packages/client/ui-directory-picker-browse/package.json b/packages/client/ui-directory-picker-browse/package.json index ec0a9d8ed0..f6544117ae 100644 --- a/packages/client/ui-directory-picker-browse/package.json +++ b/packages/client/ui-directory-picker-browse/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-directory-picker-browse", "description": "In-app directory browsing surface: the workspace directory-flow owner rendering the host's listing and creation primitives", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-directory-picker-native/package.json b/packages/client/ui-directory-picker-native/package.json index 9b370e32bb..1b81a714a8 100644 --- a/packages/client/ui-directory-picker-native/package.json +++ b/packages/client/ui-directory-picker-native/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-directory-picker-native", "description": "Native directory-picker surface: the renderless workspace directory-flow occupant driving the host's OS chooser", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-dockkit/README.i18n.yaml b/packages/client/ui-dockkit/README.i18n.yaml index e3f4d6f89c..c03aeded90 100644 --- a/packages/client/ui-dockkit/README.i18n.yaml +++ b/packages/client/ui-dockkit/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-dockkit/README.md -README.md: f4f76072bd16cd75bff74715f8f9437348ee036a -README.zh.md: 6d81138fcfa47879a3197bffc50f9c218c3eda1f +README.md: 41100530b77f9776c768010b53ecbc6ad67b5902 +README.zh.md: 94357e91fe2b94b915bf32f68df9e397723e3acf diff --git a/packages/client/ui-dockkit/README.md b/packages/client/ui-dockkit/README.md index f4f76072bd..41100530b7 100644 --- a/packages/client/ui-dockkit/README.md +++ b/packages/client/ui-dockkit/README.md @@ -34,10 +34,10 @@ A docking layout kit: a split tree of tabbed panes with invertible operations, a - `applyOp(state, op)` returns the next state **and the operations that undo it**. Inverses are captured when an operation runs, because by undo time the pre-operation state is gone. - Every operation carries the ids it creates, so `replay(initial, ops)` reproduces the same tree. The engine reads no clock and no random source. - `Sequencer` keeps a linear history with one entry per intent: the operations one gesture or command produced step back and forward together, a run of consecutive focus-only entries steps as one, and a new entry after stepping back drops the forward branch. -- `planSettle` is the opt-in rule that keeps every docked pane populated after an intent: panes an intent emptied are merged away, and an emptied root pane is reseeded through the embedder's factory. An embedder that wants empty panes simply does not call it. +- `planSettle` is the opt-in rule that keeps every docked pane populated after an intent: panes an intent emptied are merged away, and an emptied root pane is reseeded through the embedder's factory — withholding the factory keeps the merge and leaves the root pane empty. An embedder that wants empty panes simply does not call it, or calls it without a factory. `planDropTab` takes the same factory: with one, a sole tab released on its own pane's edge splits and the factory's tab backfills the pane it vacates (the dragged tab stays focused); without one that release changes nothing. - `DockController` is the intent layer and an observable source (`subscribe` + `getSnapshot`, whose reference only changes when the layout does). -**The components** render a layout snapshot and report settled intents — one per gesture, never a drag frame. A drag previews in local state while the gesture's own facts stay in its closure; on release the net result leaves through one `DockIntents` call — a strip release reports the caret slot as drawn, the dragged chip counted, and `planPlaceTab` turns that into the reorder or the move. That is what lets an embedder record exactly one history entry per gesture. The strip follows the WAI-ARIA tabs pattern with manual activation: the selected chip is in the tab order; Left and Right (wrapping), Home, and End move focus between chips without selecting; Enter or Space selects the focused chip through the same intent as a click. The optional `canCloseTab(tabId)` hides the chip, menu, and floating close controls; the embedder enforces closing in its intent handler. A chip is a capsule carrying its close control when allowed; the context menu (a secondary press on the chip) carries the same close plus the embedder's items, and renders in a portal positioned against the chip because the chip box clips its overflow on purpose (see below). After the chips sits the add control, which asks the embedder (`DockIntents.addTab`) to seat its seeded tab; the embedder's `canAddTab(paneId)` decides per pane whether the control is drawn at all. Copying a tab has no kit control — it is the embedder's API — and floating is the drag released clear of the surface. +**The components** render a layout snapshot and report settled intents — one per gesture, never a drag frame. A drag previews in local state while the gesture's own facts stay in its closure; on release the net result leaves through one `DockIntents` call — a strip release reports the caret slot as drawn, the dragged chip counted, and `planPlaceTab` turns that into the reorder or the move. That is what lets an embedder record exactly one history entry per gesture. The strip follows the WAI-ARIA tabs pattern with manual activation: the selected chip is in the tab order; Left and Right (wrapping), Home, and End move focus between chips without selecting; Enter or Space selects the focused chip through the same intent as a click. A chip is a capsule carrying one control, its close; the context menu (a secondary press on the chip) carries the same close plus the embedder's items — a menu that would hold no item at all never shows — and renders in a portal positioned against the chip because the chip box clips its overflow on purpose (see below). After the chips sits the add control, which asks the embedder (`DockIntents.addTab`) to seat its seeded tab; the embedder's `canAddTab(paneId)` decides per pane whether the control is drawn at all. Copying a tab has no kit control — it is the embedder's API — and floating is the drag released clear of the surface. ## Embedding it @@ -47,12 +47,12 @@ Everything host-specific arrives through props: | Contract | Carries | |---|---| | `DockLabels` | every rendered string, already localized, accessible names included | -| `TabRenderer` | one tab's body (`renderTab`), and optionally what its chip or panel header shows as a title (`renderTabTitle`, falling back to the record's `title`); the embedder dispatches on `tab.kind` | +| `TabRenderer` | one tab's body (`renderTab`), drawn flush to the pane's edges and the unbordered strip's bottom edge with the insets it chooses, and optionally what its chip or panel header shows as a title (`renderTabTitle`, falling back to the record's `title`); the embedder dispatches on `tab.kind` | | `DockIntents` | the settled results of every gesture | -`DockController` satisfies `DockIntents` as written, so the simplest embedding hands the controller straight to `DockSurface`. An embedder that routes through its own store implements the same method names instead. Two props carry control policy rather than gestures: `canSplit` (surface-wide, the pane budget; disables the split control with `splitPaneDisabled`) and `canAddTab(paneId)` (per pane, omits the add control; leave it out to draw one in every pane). Hiding the add control moves nothing else in the strip. The kit adds one policy of its own, the room rule below, which disables a pane's split control with `splitPaneNarrow`; `onRoom(fits)` reports its readings so an embedder splitting programmatically can honour the same rule. +`DockController` satisfies `DockIntents` as written, so the simplest embedding hands the controller straight to `DockSurface`. An embedder that routes through its own store implements the same method names instead. Three props carry control policy rather than gestures: `canSplit` (surface-wide, the pane budget; disables the split control with `splitPaneDisabled`), `canAddTab(paneId)` (per pane, omits the add control; leave it out to draw one in every pane), and `canCloseTab(tabId)` (per tab, withholds the chip's close control and the menu's close item together; leave it out to keep every tab closable). Hiding the add control moves nothing else in the strip, and a withheld close moves nothing in the chip — the close control paints over the title's end rather than beside it. A pane's lone chip whose close is withheld draws quiet — no capsule, no hover fill — since there is nothing to select against and nothing to do to it. The kit adds one policy of its own, the room rule below, which disables a pane's split control with `splitPaneNarrow`; `onRoom(fits)` reports its readings so an embedder splitting programmatically can honour the same rule. -`dropZones="horizontal"` offers two half-pane hints; once budget or width forbids another split, the whole body accepts a move. `minPaneFraction` sets the preview minimum, and `planResizeSplit` accepts the same minimum for the committed operation. The Sidebar uses 0.2 and enforces two panes in its own store. The generic engine retains its tree and other split directions. `hideSplitAtCapacity` hides the split control at the pane budget; its default is false, and a width-blocked control remains disabled. +`dropZones="horizontal"` offers two half-pane hints; once budget or width forbids another split, the whole body accepts a move. A hint is a dashed card inset 8px inside its region, showing the zone's glyph and `labels.dropZone[zone]`; the card under the pointer takes the accent and its neighbour stays a quiet outline. `minPaneFraction` sets the preview minimum, and `planResizeSplit` accepts the same minimum for the committed operation. The Sidebar uses 0.2 and enforces two panes in its own store. The generic engine retains its tree and other split directions. `hideSplitWhenBlocked` hides a blocked split control — pane budget spent or pane too narrow — instead of rendering it disabled; its default is false. A tab's `kind` is an opaque string. Seeded tabs are factories (`DockControllerOptions`), so what a fresh pane contains is the embedder's decision, not this package's. Content identity is the pair (`kind`, `contentId`): `findContentTab(state, contentId, kind?)` finds the tab showing it anywhere and `findPaneContentTab(state, paneId, contentId, kind?)` within one pane, and `planOpenContent` focuses that tab instead of opening another unless told `revealIfOpened: false`; an explicit `index` seats a new tab at a strip slot rather than at the end. @@ -64,18 +64,16 @@ A tab's `kind` is an opaque string. Seeded tabs are factories (`DockControllerOp These are not stylistic; each one fixes a defect found in a real browser. - **Capture the pointer** when a gesture starts. Without it any scroll container the pointer crosses can claim the gesture, which the browser reports as a cancelled pointer and an abandoned drag. Capture is hardening — the window listeners carry the gesture either way, so an environment without the API still works. -- **The chips give way; the strip's end controls never do.** The chip box is the strip's one shrinking part (`flex: 0 1 auto; min-width: 0; overflow: hidden`); the add, split, and chrome controls are `flex: none`, so they keep their width and place in any pane at least as wide as they are (about 130px with the chrome, 72px without). The surface's `min-width: 0` and the pane's `overflow: hidden` stop a body's longest unwrapped line from widening the pane past its box, which is what carried the controls and the body's scrollbar off-screen. -- **The chip box is not a scroll container.** A horizontal scroller claims press-and-move for itself; tabs shrink, ellipsize, and then clip instead. -- **A split needs room for two working halves.** A pane splits into equal halves, so each half must hold what cannot shrink: the strip's fixed part — measured as the strip's width minus the chip box and the fill, which is the padding, the gaps, and every control that pane draws (its own chrome included, so the top-right pane asks more) — plus one chip at its minimum — `.tab` declares `min-width: 44px` on a content-box, so its footprint is 44px plus 10px + 5px of padding, 59px, read from a rendered chip's computed style (the stylesheet figure when none can be read); the divider between the halves takes its rendered thickness (4px). A column split, which only an edge drop makes, needs each half to hold the strip (36px) plus a 48px body: one 13px secondary line at 1.6 line-height inside the body's 12px padding. `halvesFit` in `geometry.ts` is the arithmetic; `measure.ts` reads the rectangles after every commit and whenever the surface resizes, because the layout state carries fractions, never pixels, and the engine's planners stay that way. A pane without room keeps its split control, disabled with `splitPaneNarrow`, and offers no edge drop zone for that axis (the release is then not a move). A pane the user narrows afterwards — a divider or the embedder's column dragged — keeps its size: the rule only decides its next split. +- **The chips give way; the strip's end controls never do.** The chip box is the strip's one shrinking part (`flex: 0 1 auto; min-width: 0; overflow-x: auto`): chips shrink down to an 80px floor and then scroll on the wheel with no scrollbar drawn, and the box fades its chips out over 24px at each side that hides some (`data-dockkit-strip-scroll`, written from the box's scroll reading after each commit, scroll, and resize). Whenever the active tab or the row of chips changes, the box scrolls so the active chip stands clear of the fade band; a chip already in view moves nothing. A chip's title is never ellipsized: `TabTitle` reads its text against its box and sets `data-dockkit-tab-clipped` while the text is wider, which fades the text out over its last 16px. A chip's close control shows while the chip is active, hovered, or holds focus, over the title's last 14px, which fade under it, so the chip is the same width either way. The two slots beside the active chip draw no hairline, so the filled capsule stands between bare chips. The add, split, and chrome controls are `flex: none`, so they keep their width and place in any pane at least as wide as they are (about 130px with the chrome, 72px without). The surface's `min-width: 0` and the pane's `overflow: hidden` stop a body's longest unwrapped line from widening the pane past its box, which is what carried the controls and the body's scrollbar off-screen. +- **The chip box scrolls, but never claims a gesture.** A horizontal scroller would take press-and-move for itself and cancel the pointer; the box, the chips, and the strip set `touch-action: none` and the gesture captures the pointer, so a press-and-move on a chip is a drag and only the wheel scrolls the box. +- **A split needs room for two working halves.** A pane splits into equal halves, so each half must hold what cannot shrink: the strip's fixed part — measured as the strip's width minus the chip box and the fill, which is the padding, the gaps, and every control that pane draws (its own chrome included, so the top-right pane asks more) — plus one chip at its minimum — `.tab` declares `min-width: 80px` on a content-box, so its footprint is 80px plus 10px + 10px of padding, 100px, read from a rendered chip's computed style (the stylesheet figure when none can be read); the divider between the halves takes its rendered thickness (0 — its hairline paints over the seam without taking layout room, so a body's own rules run unbroken past it). A column split, which only an edge drop makes, needs each half to hold the strip (34px) plus a 48px body: one 13px secondary line at 1.6 line-height inside 12px of the body's own insets — the pane body itself is unpadded, so a tab's body reaches the strip's bottom edge and the pane's edges and draws its own. `halvesFit` in `geometry.ts` is the arithmetic; `measure.ts` reads the rectangles after every commit and whenever the surface resizes, because the layout state carries fractions, never pixels, and the engine's planners stay that way. A pane without room keeps its split control, disabled with `splitPaneNarrow` (hidden instead under `hideSplitWhenBlocked`), and offers no edge drop zone for that axis (the release is then not a move). Under `hideSplitWhenBlocked` the split control's own footprint — its box plus the strip's gap — is left out of the fixed part: hiding the control sheds exactly that footprint from the strip, so a reading that counted it would flip with the control's visibility and re-render forever; leaving it out is also what the half being asked about would carry, since a half too narrow to split hides its own control. A pane the user narrows afterwards — a divider or the embedder's column dragged — keeps its size: the rule only decides its next split. - **Focus lands on click, not on press.** A state change between `pointerdown` and the first `pointermove` rebuilds the pressed subtree, and a replaced element cancels the pointer. It also keeps a drag from recording a redundant focus operation first. Clicks on the chips, the strip's controls, and the embedder's chrome stop at the strip: the intent each reports already decides the active pane, or is the embedder's own, so the pane's click-to-focus records nothing extra. A floating panel's grip and corner report through their gesture the same way — a press released in place is a click that raises the panel, and a drag records only the move or resize, whose operation raises it — while a press on the panel's body raises it directly. A click on the pane that is active already, a click or key on that pane's selected chip, or a press on the panel that is active and on top already, changes nothing and records nothing. - **A control nested inside a draggable chip stops its own press.** Otherwise the press starts a drag, captures the pointer, and the nested control's click never lands. -- **Emphasis takes the platform's accent, never `--dsw-alias-brand-primary`.** This platform binds `brand-primary` to its near-black (light) or near-white (dark) foreground, so a hovered divider, the drop caret, and the drop-zone hint use `--dsw-alias-brand-primary-new-colorprimary-new-color`, as the trajectory views do. A floating panel's border is the same `--dsw-alias-border-l2` hairline whether it is active or not: the active panel is already on top and casts the shadow; a darker frame around it read as a defect. +- **Emphasis takes the platform's accent, never `--dsw-alias-brand-primary`.** This platform binds `brand-primary` to its near-black (light) or near-white (dark) foreground, so the drop caret and the drop-zone hint use `--dsw-alias-brand-primary-new-colorprimary-new-color`, as the trajectory views do; a hovered divider takes the caption label ink instead, reading as a handle rather than a highlight. A floating panel draws no border — the menu's shadow (`--dsw-elevation-prominent`) outlines it — and the active panel gets no heavier frame: it is already on top and casts the same shadow; a darker frame around it read as a defect. ## Build shape -Its static ESM retains third-party imports for the Web shell's Vite build; independent consumers supply its development dependencies ([dependency rules](../AGENTS.md#dependency-declaration)). - The package is statically linked: tsdown's `staticLinked` preset emits one browser ESM bundle at `lib/index.js` (every bare specifier stays an import, sourcemaps chain to the sources) and ships the stylesheet under `lib/` at its `src`-relative path, and the Web shell resolves the package name and bundles that artifact itself, so vite stays the only owner of class hashing. One consequence is load-bearing — the kit keeps **one** stylesheet, `dockkit.module.css`, because a consumer de-duplicates injected sheets by file name and a collision would drop one silently. diff --git a/packages/client/ui-dockkit/README.zh.md b/packages/client/ui-dockkit/README.zh.md index 6d81138fcf..94357e91fe 100644 --- a/packages/client/ui-dockkit/README.zh.md +++ b/packages/client/ui-dockkit/README.zh.md @@ -34,10 +34,10 @@ kind: "package-reference" - `applyOp(state, op)` 返回下一状态**以及撤销它的操作**。逆操作在操作执行时捕获,因为到撤销时操作前的状态已经不存在了。 - 每个操作都携带它创建的 id,因此 `replay(initial, ops)` 能复现同一棵树。引擎不读时钟,也不读随机源。 - `Sequencer` 维护一条线性历史,每个意图一条记录:一次手势或命令产生的操作一起后退、一起前进,连续的纯焦点记录作为一步,后退后的新记录会丢弃前进分支。 -- `planSettle` 是可选加入的规则,保证意图之后每个停靠格都有内容:被意图清空的格会被并掉,被清空的根格通过嵌入方的工厂重新播种。想要空格的嵌入方只需不调用它。 +- `planSettle` 是可选加入的规则,保证意图之后每个停靠格都有内容:被意图清空的格会被并掉,被清空的根格通过嵌入方的工厂重新播种——不传工厂则只并格、让根格保持为空。想要空格的嵌入方只需不调用它,或不带工厂调用。`planDropTab` 接受同一工厂:带工厂时,唯一 tab 放到本格边缘会分栏,工厂的 tab 回填它腾出的格(被拖的 tab 保持聚焦);不带工厂时这种释放不改变任何东西。 - `DockController` 是意图层,也是一个可观察源(`subscribe` + `getSnapshot`,其引用只在布局变化时才变)。 -**组件**渲染布局快照并上报已落定的意图——每次手势一条,绝不上报拖动帧。拖动过程中在本地状态里预览,手势自身的事实留在它的闭包里;松手时净结果通过一次 `DockIntents` 调用离开——在标签条上松手上报的是按绘制顺序数出的插入槽位(被拖的 chip 也计入),由 `planPlaceTab` 换算成重排或移动。正是这一点让嵌入方能为每次手势记录恰好一条历史。标签条遵循 WAI-ARIA tabs 模式的手动激活:选中的 chip 在 Tab 键序里;左右方向键(循环)、Home、End 只在 chip 之间移动焦点而不选中;Enter 或空格选中当前聚焦的 chip,走与点击相同的意图。可选的 `canCloseTab(tabId)` 隐藏 chip、菜单和浮窗的关闭控件;嵌入方在意图处理器中执行关闭限制。chip 是一个胶囊,在允许关闭时携带关闭按钮;上下文菜单(在 chip 上的次键按下)携带同样的关闭项加上嵌入方的条目,并渲染在按 chip 定位的 portal 里,因为 chip 盒会故意裁掉溢出(见下文)。chip 之后是添加控件,它请嵌入方(`DockIntents.addTab`)安放其种子 tab;嵌入方的 `canAddTab(paneId)` 按格决定是否绘制该控件。复制 tab 没有套件控件——那是嵌入方的 API——而浮出就是把拖动松手在停靠区之外。 +**组件**渲染布局快照并上报已落定的意图——每次手势一条,绝不上报拖动帧。拖动过程中在本地状态里预览,手势自身的事实留在它的闭包里;松手时净结果通过一次 `DockIntents` 调用离开——在标签条上松手上报的是按绘制顺序数出的插入槽位(被拖的 chip 也计入),由 `planPlaceTab` 换算成重排或移动。正是这一点让嵌入方能为每次手势记录恰好一条历史。标签条遵循 WAI-ARIA tabs 模式的手动激活:选中的 chip 在 Tab 键序里;左右方向键(循环)、Home、End 只在 chip 之间移动焦点而不选中;Enter 或空格选中当前聚焦的 chip,走与点击相同的意图。chip 是一个胶囊,携带唯一的控件——它的关闭按钮;上下文菜单(在 chip 上的次键按下)携带同样的关闭项加上嵌入方的条目——一个连一项都没有的菜单绝不展示——并渲染在按 chip 定位的 portal 里,因为 chip 盒会故意裁掉溢出(见下文)。chip 之后是添加控件,它请嵌入方(`DockIntents.addTab`)安放其种子 tab;嵌入方的 `canAddTab(paneId)` 按格决定是否绘制该控件。复制 tab 没有套件控件——那是嵌入方的 API——而浮出就是把拖动松手在停靠区之外。 ## 如何嵌入 @@ -47,12 +47,12 @@ kind: "package-reference" | 约定 | 承载内容 | |---|---| | `DockLabels` | 每一个渲染出来的字符串,已本地化,含无障碍名称 | -| `TabRenderer` | 一个 tab 的正文(`renderTab`),以及可选的 chip 或浮窗头部显示的标题(`renderTabTitle`,回退到记录的 `title`);嵌入方按 `tab.kind` 分发 | +| `TabRenderer` | 一个 tab 的正文(`renderTab`),贴着格的边缘和(不带边线的)tab 条底边绘制、自己决定留白,以及可选的 chip 或浮窗头部显示的标题(`renderTabTitle`,回退到记录的 `title`);嵌入方按 `tab.kind` 分发 | | `DockIntents` | 每次手势落定的结果 | -`DockController` 原样满足 `DockIntents`,所以最简单的嵌入就是把 controller 直接交给 `DockSurface`。经由自己 store 路由的嵌入方则实现同名方法。有两个 props 承载的是控制策略而非手势:`canSplit`(整面有效,即格预算;用 `splitPaneDisabled` 禁用分栏控件)与 `canAddTab(paneId)`(按格,省略添加控件;不传则每格都画)。隐藏添加控件不会移动 tab 条里的其它任何东西。套件自己再加一条策略,即下文的空间规则,它用 `splitPaneNarrow` 禁用某格的分栏控件;`onRoom(fits)` 上报其读数,让以编程方式分栏的嵌入方能遵守同一规则。 +`DockController` 原样满足 `DockIntents`,所以最简单的嵌入就是把 controller 直接交给 `DockSurface`。经由自己 store 路由的嵌入方则实现同名方法。有三个 props 承载的是控制策略而非手势:`canSplit`(整面有效,即格预算;用 `splitPaneDisabled` 禁用分栏控件)、`canAddTab(paneId)`(按格,省略添加控件;不传则每格都画)与 `canCloseTab(tabId)`(按 tab,把 chip 的关闭控件和菜单的关闭项一并收起;不传则每个 tab 都可关闭)。隐藏添加控件不会移动 tab 条里的其它任何东西,收起关闭也不会移动 chip 里的任何东西——关闭控件压在标题末端之上而非并排。某格仅剩的一个 chip 在关闭被收起时画成安静样式——没有胶囊底色,没有悬停填充——因为既没有别的 tab 可供选择,也没有任何可对它做的事。套件自己再加一条策略,即下文的空间规则,它用 `splitPaneNarrow` 禁用某格的分栏控件;`onRoom(fits)` 上报其读数,让以编程方式分栏的嵌入方能遵守同一规则。 -`dropZones="horizontal"` 提供左右两个半区提示;预算或宽度不允许再拆时,正文整格接收移动。`minPaneFraction` 控制预览的最小比例,`planResizeSplit` 接受相同最小值以约束提交;Sidebar 使用 0.2,并在自己的 store 中限制为两格。通用引擎仍保留原有树与其他分割方向。`hideSplitAtCapacity` 在达到窗格预算时隐藏分栏控件,默认值为 `false`;宽度不足的控件仍以禁用状态显示。 +`dropZones="horizontal"` 提供左右两个半区提示;预算或宽度不允许再拆时,正文整格接收移动。提示是一张内缩 8px 的虚线卡片,显示该落区的图形和 `labels.dropZone[zone]`;指针所在的卡片取强调色,另一张保持安静的轮廓。`minPaneFraction` 控制预览的最小比例,`planResizeSplit` 接受相同最小值以约束提交;Sidebar使用0.2并在自己的store限制两格。通用引擎仍保留原有树与其它分割方向。 `hideSplitWhenBlocked` 在分栏被阻止时(窗格预算已满或格太窄)直接隐藏分栏控件而不是渲染禁用态,默认值为 false。 tab 的 `kind` 是不透明字符串。种子 tab 是工厂(`DockControllerOptions`),因此新格里放什么由嵌入方决定,与本包无关。内容身份是二元组(`kind`、`contentId`):`findContentTab(state, contentId, kind?)` 在任意位置找到展示它的 tab,`findPaneContentTab(state, paneId, contentId, kind?)` 在一个格内找;`planOpenContent` 会聚焦该 tab 而非再开一个,除非被告知 `revealIfOpened: false`;显式的 `index` 把新 tab 放到 tab 条的某个位置而非末尾。 @@ -64,24 +64,22 @@ tab 的 `kind` 是不透明字符串。种子 tab 是工厂(`DockControllerOpt 这些不是风格偏好;每一条都修复了在真实浏览器里发现的缺陷。 - **手势开始时捕获指针。** 不捕获的话,指针经过的任何滚动容器都可能接管手势,浏览器会将其报告为指针取消和拖动中止。捕获是加固——无论如何都由 window 监听器承载手势,所以没有该 API 的环境照样可用。 -- **chip 让位;tab 条末端的控件永不让位。** chip 盒是 tab 条里唯一会收缩的部分(`flex: 0 1 auto; min-width: 0; overflow: hidden`);添加、分栏与 chrome 控件都是 `flex: none`,因此在任何不窄于它们自身的格里(带 chrome 约 130px,不带约 72px)都保持宽度与位置。停靠面的 `min-width: 0` 与格的 `overflow: hidden` 阻止正文里最长的不换行行把格撑出自己的盒子——正是那种情况把控件和正文滚动条推到了屏幕外。 -- **chip 盒不是滚动容器。** 横向滚动容器会把按下并移动的手势据为己有;tab 转而收缩、省略、然后被裁切。 -- **分栏需要给两个可用的半格留出空间。** 格被等分成两半,因此每一半都必须容得下不可收缩的部分:tab 条的固定部分——按 tab 条宽减去 chip 盒与填充条测得,即内边距、间隙以及该格绘制的每个控件(含它自己的 chrome,所以右上格要求更多)——加上一枚最小尺寸的 chip——`.tab` 在 content-box 上声明 `min-width: 44px`,所以它的足印是 44px 加 10px + 5px 内边距,即 59px,从已渲染 chip 的计算样式读取(读不到时用样式表数值);两半之间的分隔条取其渲染厚度(4px)。纵向分栏只由边缘落下产生,它要求每一半容得下 tab 条(36px)加 48px 正文:正文 12px 内边距内一行 13px、行高 1.6 的次级文字。`geometry.ts` 里的 `halvesFit` 是算术;`measure.ts` 在每次提交后与停靠面尺寸变化时读取矩形,因为布局状态只携带比例、从不携带像素,引擎的 planner 也保持如此。没有空间的格保留分栏控件,以 `splitPaneNarrow` 禁用,并且在该轴上不提供边缘落区(松手就不是移动)。用户随后拖窄的格——通过拖动分隔条或嵌入方的列——会保持原尺寸:规则只决定它的下一次分栏。 +- **chip 让位;tab 条末端的控件永不让位。** chip 盒是 tab 条里唯一会收缩的部分(`flex: 0 1 auto; min-width: 0; overflow-x: auto`):chip 先缩到 80px 下限,再在盒内随滚轮横向滚动、不画滚动条,且盒在每个藏有 chip 的一侧把 chip 在 24px 内渐隐(`data-dockkit-strip-scroll`,在每次提交、滚动与尺寸变化后由盒的滚动读数写入)。每当活动 tab 或 chip 的排列变化,盒会滚动到让活动 chip 避开渐隐带;已在视野内的 chip 不动。chip 的标题从不加省略号:`TabTitle` 拿文字宽度对照它的盒子,文字更宽时置 `data-dockkit-tab-clipped`,让文字在末端 16px 内渐隐。chip 的关闭控件在 chip 活动、悬停或持有焦点时显示,压在标题末端 14px 之上、标题在其下渐隐,因此 chip 宽度两种情况下都一样。活动 chip 两侧的槽不画细线,让填色胶囊立在裸 chip 之间。添加、分栏与 chrome 控件都是 `flex: none`,因此在任何不窄于它们自身的格里(带 chrome 约 130px,不带约 72px)都保持宽度与位置。停靠面的 `min-width: 0` 与格的 `overflow: hidden` 阻止正文里最长的不换行行把格撑出自己的盒子——正是那种情况把控件和正文滚动条推到了屏幕外。 +- **chip 盒会滚动,但绝不认领手势。** 横向滚动容器会把按下并移动的手势据为己有并取消指针;盒、chip 与 tab 条都设 `touch-action: none`,手势又捕获了指针,所以在 chip 上按下并移动是拖动,只有滚轮滚动盒子。 +- **分栏需要给两个可用的半格留出空间。** 格被等分成两半,因此每一半都必须容得下不可收缩的部分:tab 条的固定部分——按 tab 条宽减去 chip 盒与填充条测得,即内边距、间隙以及该格绘制的每个控件(含它自己的 chrome,所以右上格要求更多)——加上一枚最小尺寸的 chip——`.tab` 在 content-box 上声明 `min-width: 80px`,所以它的足印是 80px 加 10px + 10px 内边距,即 100px,从已渲染 chip 的计算样式读取(读不到时用样式表数值);两半之间的分隔条取其渲染厚度(0——它的细线画在接缝上、不占布局空间,因此正文自己画的分隔线能不断线地穿过接缝)。纵向分栏只由边缘落下产生,它要求每一半容得下 tab 条(34px)加 48px 正文:正文自留的 12px 内边距内一行 13px、行高 1.6 的次级文字——格的正文容器本身没有内边距,tab 的正文直接贴到 tab 条底边和格的边缘,由自己留白。`geometry.ts` 里的 `halvesFit` 是算术;`measure.ts` 在每次提交后与停靠面尺寸变化时读取矩形,因为布局状态只携带比例、从不携带像素,引擎的 planner 也保持如此。没有空间的格保留分栏控件,以 `splitPaneNarrow` 禁用(开启 `hideSplitWhenBlocked` 时改为隐藏),并且在该轴上不提供边缘落区(松手就不是移动)。开启 `hideSplitWhenBlocked` 时,分栏控件自己的占位——它的盒子加 tab 条的间隙——不计入固定部分:隐藏控件让 tab 条卸下的恰是这份占位,把它算进去的读数会随控件的可见性来回翻转、无限重渲染;不计入也正是被询问的那一半会承载的量,因为窄到无法分栏的一半会隐藏自己的控件。用户随后拖窄的格——通过拖动分隔条或嵌入方的列——会保持原尺寸:规则只决定它的下一次分栏。 - **焦点落在 click 而不是按下。** 在 `pointerdown` 与第一次 `pointermove` 之间的状态变化会重建被按下的子树,而被替换的元素会取消指针。这也避免拖动先记录一条多余的焦点操作。chip、标签条各控件以及嵌入方 chrome 上的 click 都止于标签条:它们各自上报的意图已决定了活动格,或本就是嵌入方自己的事,所以格自身的点击聚焦不再多记一条。浮动面板的抓手与角柄同样通过手势上报——原地松开的按下是一次 click,抬起面板;真正的拖动只记录移动或缩放,由该操作自己抬起面板——而按在面板主体上则直接抬起它。点击本已活动的格、点击或按键选中该格本已选中的 chip,或按下本已活动且在最上层的面板,什么都不改变,也什么都不记录。 - **嵌套在可拖动 chip 里的控件要拦住自己的按下。** 否则按下会开始拖动、捕获指针,嵌套控件的 click 就永远落不下。 -- **强调色用平台的强调 token,绝不用 `--dsw-alias-brand-primary`。** 本平台把 `brand-primary` 绑定到近黑(浅色)或近白(深色)的前景色,因此悬停的分隔条、落点光标与落区提示都用 `--dsw-alias-brand-primary-new-colorprimary-new-color`,与轨迹视图一致。浮窗的边框无论是否活动都是同一条 `--dsw-alias-border-l2` 细线:活动浮窗本就在最上层并投下阴影;围它一圈更深的边框读起来像缺陷。 +- **强调色用平台的强调 token,绝不用 `--dsw-alias-brand-primary`。** 本平台把 `brand-primary` 绑定到近黑(浅色)或近白(深色)的前景色,因此落点光标与落区提示都用 `--dsw-alias-brand-primary-new-colorprimary-new-color`,与轨迹视图一致;悬停的分隔条改用 caption 文字色,读起来是把手而不是高亮。浮窗不画边框——菜单同款阴影(`--dsw-elevation-prominent`)已勾出它的轮廓——活动浮窗也不加重边框:它本就在最上层、投同样的阴影;围它一圈更深的边框读起来像缺陷。 ## 构建形态 -静态 ESM 为 Web 壳的 Vite 构建保留第三方导入;独立消费方自行提供开发依赖([依赖规则](../AGENTS.md#dependency-declaration))。 - 本包静态链接:tsdown 的 `staticLinked` 预设在 `lib/index.js` 产出一个浏览器 ESM bundle(所有裸说明符保持为 import,sourcemap 链回源码),并把样式表按其相对 `src` 的路径放到 `lib/` 下;Web 外壳按包名解析并自行打包该产物,因此 vite 仍是 class 哈希的唯一拥有者。有一个后果至关重要——套件只保留**一张**样式表 `dockkit.module.css`,因为消费方按文件名去重注入的样式表,撞名会静默丢掉一张。 ## 模型体验 -无;本包是浏览器侧停靠布局引擎与组件集,不注册任何面向模型的内容。 +无,因为本包是浏览器侧停靠布局引擎与组件集,不注册任何面向模型的内容。 #### KV Cache 影响 diff --git a/packages/client/ui-dockkit/package.json b/packages/client/ui-dockkit/package.json index 968d1ba505..df264e82f8 100644 --- a/packages/client/ui-dockkit/package.json +++ b/packages/client/ui-dockkit/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-dockkit", "description": "Docking layout kit: split-tree engine with invertible operations, and the React components that render and drive it (zero cordis)", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, @@ -25,6 +25,7 @@ "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", diff --git a/packages/client/ui-dockkit/src/components/DockSurface.tsx b/packages/client/ui-dockkit/src/components/DockSurface.tsx index 6c074c86ca..6c3e0ced4d 100644 --- a/packages/client/ui-dockkit/src/components/DockSurface.tsx +++ b/packages/client/ui-dockkit/src/components/DockSurface.tsx @@ -33,8 +33,8 @@ export interface DockSurfaceProps { * split control disabled with `labels.splitPaneNarrow` (see README). */ readonly canSplit: boolean - /** Hide the split control when the pane budget is spent; defaults to false. Width-blocked controls remain disabled. */ - readonly hideSplitAtCapacity?: boolean + /** Hide a blocked split control — pane budget spent or pane too narrow — instead of rendering it disabled; defaults to false. */ + readonly hideSplitWhenBlocked?: boolean /** Body drop geometry: all edge bands, or left/right halves with whole-pane moves once splitting is unavailable. */ readonly dropZones?: 'edges' | 'horizontal' /** Smallest share a divider may leave a pane; defaults to the kit's fraction. */ @@ -45,7 +45,13 @@ export interface DockSurfaceProps { * end controls where they are and the chips as the only shrinking part. */ readonly canAddTab?: (paneId: PaneId) => boolean - /** Whether a tab offers close controls; defaults to true. Called per tab on every render. */ + /** + * Whether a tab draws its close control and its menu's close item. Called + * per rendered chip on every render; omit to keep every tab closable. + * `false` removes both routes without moving the chip: the close control + * paints over the title's end rather than beside it, so the chip is the same + * width either way. The menu still opens and carries the embedder's items. + */ readonly canCloseTab?: (tabId: TabId) => boolean readonly intents: DockIntents readonly labels: DockLabels @@ -97,7 +103,7 @@ const NO_PREVIEW: Preview = { draggingTabId: undefined, dropTarget: undefined, s /** Nothing measured yet: every pane fits until a reading says otherwise. */ const NO_FITS: ReadonlyMap = new Map() -/** The default add-control policy: every pane offers one. */ +/** The default policy for the omitted callbacks: every pane offers the add control, every tab its close. */ const ALWAYS = (): boolean => true /** @@ -157,7 +163,7 @@ function sameSizes(a: readonly number[], b: readonly number[]): boolean { /** The split tree and the gestures over it. */ export function DockSurface({ state, canSplit, canAddTab, canCloseTab, intents, labels, renderTab, renderTabTitle, renderTabMenuItems, chrome, onRoom, - dropZones = 'edges', minPaneFraction = MIN_PANE_FRACTION, hideSplitAtCapacity = false, + dropZones = 'edges', minPaneFraction = MIN_PANE_FRACTION, hideSplitWhenBlocked = false, }: DockSurfaceProps): ReactNode { const surface = useRef(null) const [preview, setPreview] = useState(NO_PREVIEW) @@ -178,10 +184,10 @@ export function DockSurface({ // wider or narrower). A reading that changed nothing renders nothing. const remeasure = useCallback((): void => { withSurface((root) => { - const next = measurePaneFits(root) + const next = measurePaneFits(root, hideSplitWhenBlocked) setFits(current => sameFits(current, next) ? current : next) }) - }, [withSurface]) + }, [withSurface, hideSplitWhenBlocked]) useLayoutEffect(() => { remeasure() }) useEffect(() => { onRoom?.(fits) }, [fits, onRoom]) useEffect(() => { @@ -267,7 +273,7 @@ export function DockSurface({ }) }, splitBlock, - hideSplitAtCapacity, + hideSplitWhenBlocked, canAddTab: canAddTab ?? ALWAYS, canCloseTab: canCloseTab ?? ALWAYS, dropTarget: preview.dropTarget, diff --git a/packages/client/ui-dockkit/src/components/FloatLayer.tsx b/packages/client/ui-dockkit/src/components/FloatLayer.tsx index 4247928b21..d929339953 100644 --- a/packages/client/ui-dockkit/src/components/FloatLayer.tsx +++ b/packages/client/ui-dockkit/src/components/FloatLayer.tsx @@ -1,7 +1,8 @@ /** * The floating layer: one overlay panel per floating pane, bottom-to-top in the - * model's z order. A floating pane hosts exactly one tab and renders no tab - * strip — the panel *is* the tab. Pressing a panel's body raises it. Its grip + * model's z order. A floating pane hosts exactly one tab; its header is the + * strip's row holding that tab's chip, never selectable or closable from the + * chip, and the send-back and close controls. Pressing a panel's body raises it. Its grip * and corner report through their gesture instead: a press released in place is * a click and raises the panel; a drag records the move or resize, and that * operation raises the panel itself, so one gesture is one intent. Raising a @@ -14,12 +15,15 @@ */ import { useState } from 'react' import type { PointerEvent as ReactPointerEvent, ReactNode } from 'react' +import clsx from 'clsx' +import { IconCloseOutline16, IconPanelLeftOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { DockIntents, DockLabels, TabRenderer } from '../contract/adapter.ts' import type { FloatRect, LayoutState, PaneId, TabId } from '../contract/types.ts' import { FLOAT_MIN_SIZE } from '../engine/constraints.ts' import { movedRect, resizedRect } from '../engine/geometry.ts' import { floatRect, getPane, getTab, onlyTabId } from '../engine/tree.ts' import { useGesture } from './pointer.ts' +import { TabTitle } from './TabTitle.tsx' import css from './dockkit.module.css' /** The layout whose `floats` this layer draws. */ @@ -114,32 +118,39 @@ export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle, onPointerDown={() => { raise(paneId) }} >
{ drag('move', paneId, event) }} > - {renderTabTitle?.(tab) ?? tab.title} - - {(canCloseTab?.(tab.id) ?? true) && ( +
+ {renderTabTitle?.(tab) ?? tab.title} +
+
+ + + {(canCloseTab?.(tab.id) ?? true) && ( + + + )}
{renderTab(tab)}
diff --git a/packages/client/ui-dockkit/src/components/TabMenu.tsx b/packages/client/ui-dockkit/src/components/TabMenu.tsx index 93634d0e12..d3f458c8b4 100644 --- a/packages/client/ui-dockkit/src/components/TabMenu.tsx +++ b/packages/client/ui-dockkit/src/components/TabMenu.tsx @@ -2,8 +2,10 @@ * The per-tab context menu, opened by a secondary press on the chip. It carries * the close gesture and whatever the embedder appends; the copy and float * gestures have no menu item — copying is an embedder API, floating is a drag - * released clear of the surface. Presentational — it renders what its props - * supply and dismisses itself on outside presses. + * released clear of the surface. A menu that would hold no item at all renders + * no popup, so a secondary press on a chip with nothing to offer shows nothing. + * Presentational — it renders what its props supply and dismisses itself on + * outside presses. * * It renders in a portal, positioned against the control that opened it. The tab * strip clips its overflow on purpose (so it never becomes a scroll container @@ -26,12 +28,11 @@ export interface TabMenuProps { readonly labels: DockLabels /** The control that opened the menu; the menu hangs below its left edge. */ readonly anchor: HTMLElement - /** Whether to offer close; custom items remain available when false. */ - readonly canCloseTab: boolean - readonly onClose: () => void + /** Close the tab; `undefined` removes the kit's item, leaving the extras only. */ + readonly onClose: (() => void) | undefined /** Dismiss without acting. */ readonly onDismiss: () => void - /** Embedder ARIA menu items, rendered after the kit's own; absent means none. */ + /** Embedder items, rendered after the kit's own; absent means none. */ readonly extras: ReactNode } @@ -49,18 +50,19 @@ function placeMenu(anchor: HTMLElement, menu: HTMLElement): CSSProperties { } /** The actions menu body, anchored to the control that opened it. */ -export function TabMenu({ labels, anchor, canCloseTab, onClose, onDismiss, extras }: TabMenuProps): ReactNode { +export function TabMenu({ labels, anchor, onClose, onDismiss, extras }: TabMenuProps): ReactNode { const self = useRef(null) const [position, setPosition] = useState(undefined) - const hasItems = canCloseTab || Children.toArray(extras).some(item => item !== '') + const hasItems = onClose !== undefined || Children.toArray(extras).some(item => item !== '') useLayoutEffect(() => { if (self.current === null) return setPosition(placeMenu(anchor, self.current)) - }, [anchor, canCloseTab, hasItems]) + }, [anchor, hasItems]) useEffect(() => { const menu = self.current + /* v8 ignore next -- the ref is attached by effect time: the menu renders unconditionally. */ if (menu === null) return undefined // A press anywhere but inside the menu dismisses it; one with no element // target (dispatched to the window itself) counts as outside. @@ -91,7 +93,7 @@ export function TabMenu({ labels, anchor, canCloseTab, onClose, onDismiss, extra onPointerDown={(event) => { event.stopPropagation() }} onClick={(event) => { event.stopPropagation() }} > - {canCloseTab && ( + {onClose !== undefined && ( diff --git a/packages/client/ui-dockkit/src/components/TabPanel.tsx b/packages/client/ui-dockkit/src/components/TabPanel.tsx index 1258002996..47f025dab5 100644 --- a/packages/client/ui-dockkit/src/components/TabPanel.tsx +++ b/packages/client/ui-dockkit/src/components/TabPanel.tsx @@ -3,48 +3,92 @@ * active tab's body with the dock preview overlay. Presentational; every gesture * leaves through `PaneCallbacks`, and the body itself comes from `renderTab`. * - * A chip is a capsule carrying an optional close control at its right end; the - * context menu (secondary press) carries the same close plus whatever the - * embedder appends. The chips sit in their own box, the strip's one shrinking - * part: in a narrow pane they ellipsize and then clip there, so the add - * control after them (drawn while the embedder's `canAddTab` allows), the - * pane's split control, and the embedder's chrome keep their width and their - * place at the strip's end. + * A chip is a capsule carrying one control, its close, shown over its right + * end while the chip is active, hovered, or focused; the context menu + * (secondary press) carries the same close plus whatever the embedder appends. + * Both close routes draw only while the embedder's `canCloseTab` allows, and + * a pane's lone chip whose close is withheld draws quiet — no capsule, no + * hover fill — since there is nothing to select against and nothing to do to + * it. + * Between neighbouring chips sits a slot: a fixed-width box drawing a + * hairline, blank beside the active chip, and the drop caret when a drag + * targets that index, so a caret never widens the row; the two end slots + * exist only while targeted. The chips sit in their own box, the strip's one + * shrinking part: in a narrow pane their titles fade at the clipped edge down + * to the chip's floor and then the chips scroll there, keeping the active one + * in view, so the add control after them (drawn while the embedder's + * `canAddTab` allows), the pane's split control, and the embedder's chrome keep + * their width and their place at the strip's end. */ -import { Fragment, useState } from 'react' -import type { ReactNode } from 'react' +import { Fragment, useLayoutEffect, useRef, useState } from 'react' +import type { ReactNode, RefObject } from 'react' import clsx from 'clsx' -import type { LayoutState, PaneNode, TabId } from '../contract/types.ts' +import { IconCloseFill14, IconPlusOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' +import type { DockZone, LayoutState, PaneNode, TabId } from '../contract/types.ts' import { getTab } from '../engine/tree.ts' import type { PaneCallbacks, SplitBlock } from './render.ts' import { TabMenu } from './TabMenu.tsx' +import { TabTitle } from './TabTitle.tsx' import css from './dockkit.module.css' -/** The split control's glyph: a frame divided by a vertical line, as the split itself is. */ +/** + * The ic_ds_panel_left_outline_16 frame alone: its outer and inner rounded + * rectangles as one even-odd ring, without the divider. The glyphs below draw + * inside it so they read as siblings of the panel controls beside them. + */ +const PANEL_FRAME = 'M9.67272 0.522841C10.8339 0.522841 11.76 0.522714 12.4963 0.602493C13.2453 0.683657 13.8789 0.854248 14.4264 1.25197C14.7504 1.48739 15.0355 1.77247 15.2709 2.0965C15.6686 2.64394 15.8392 3.27758 15.9204 4.02655C16.0002 4.7629 16 5.68895 16 6.85014V9.14986C16 10.3111 16.0002 11.2371 15.9204 11.9735C15.8392 12.7224 15.6686 13.3561 15.2709 13.9035C15.0355 14.2275 14.7504 14.5126 14.4264 14.748C13.8789 15.1458 13.2453 15.3163 12.4963 15.3975C11.76 15.4773 10.8339 15.4772 9.67272 15.4772H6.3273C5.16611 15.4772 4.24006 15.4773 3.50371 15.3975C2.75474 15.3163 2.1211 15.1458 1.57366 14.748C1.24963 14.5126 0.964549 14.2275 0.729131 13.9035C0.331407 13.3561 0.160817 12.7224 0.0796529 11.9735C-0.000126137 11.2371 1.25338e-09 10.3111 1.25338e-09 9.14986V6.85014C1.25329e-09 5.68895 -0.000126137 4.7629 0.0796529 4.02655C0.160817 3.27758 0.331407 2.64394 0.729131 2.0965C0.964549 1.77247 1.24963 1.48739 1.57366 1.25197C2.1211 0.854248 2.75474 0.683657 3.50371 0.602493C4.24006 0.522714 5.16611 0.522841 6.3273 0.522841H9.67272ZM4.1828 14.0873L5.54303 14.1118C5.78636 14.1128 6.04709 14.1169 6.3273 14.1169H9.67272C10.8639 14.1169 11.7032 14.1164 12.3493 14.0465C12.9824 13.9779 13.3497 13.8494 13.6268 13.6482C13.8354 13.4966 14.0195 13.3125 14.1711 13.1039C14.3723 12.8268 14.5007 12.4595 14.5693 11.8264C14.6393 11.1803 14.6398 10.341 14.6398 9.14986V6.85014C14.6398 5.65896 14.6393 4.81967 14.5693 4.1736C14.5007 3.54048 14.3723 3.17318 14.1711 2.89609C14.0195 2.68747 13.8354 2.50337 13.6268 2.35179C13.3497 2.1506 12.9824 2.02212 12.3493 1.95353C11.7032 1.88358 10.8639 1.88307 9.67272 1.88307H6.3273C6.04709 1.88307 5.78636 1.8862 5.54303 1.88715L4.1828 1.91166C3.99125 1.9216 3.8148 1.93577 3.65076 1.95353C3.01764 2.02212 2.65034 2.1506 2.37325 2.35179C2.16463 2.50337 1.98052 2.68747 1.82895 2.89609C1.62776 3.17318 1.49928 3.54048 1.43069 4.1736C1.36074 4.81967 1.36023 5.65896 1.36023 6.85014V9.14986C1.36023 10.341 1.36074 11.1803 1.43069 11.8264C1.49928 12.4595 1.62776 12.8268 1.82895 13.1039C1.98052 13.3125 2.16463 13.4966 2.37325 13.6482C2.65034 13.8494 3.01764 13.9779 3.65076 14.0465C3.81478 14.0642 3.99127 14.0774 4.1828 14.0873Z' + +/** The split control's glyph: the panel frame with its divider moved to the centre. */ function SplitGlyph(): ReactNode { return ( -
+
+ + {labels.dropZone[zone]} +
+
) } @@ -78,10 +122,86 @@ function selects(key: string): boolean { return key === 'Enter' || key === ' ' } -/** The split control's title: what it does, or why it cannot right now. */ -function splitTitle(labels: PaneCallbacks['labels'], block: SplitBlock | undefined): string { +/** + * Which sides of the chip box hold chips scrolled out of view, as the + * `data-dockkit-strip-scroll` value the stylesheet fades: `undefined` while + * every chip is in view. + */ +function hiddenSides(box: HTMLElement): 'start' | 'end' | 'start end' | undefined { + // Sub-pixel scroll positions: a side counts as hidden past one whole pixel. + const start = box.scrollLeft > 1 + const end = box.scrollLeft + box.clientWidth < box.scrollWidth - 1 + if (start && end) return 'start end' + if (start) return 'start' + if (end) return 'end' + return undefined +} + +/** + * Keep the chip box's `data-dockkit-strip-scroll` current: read after each + * commit that can change the chips, on scroll, and on resize. Written to the + * DOM directly rather than through state because a reading never changes + * what renders, only how the stylesheet fades it. + * + * Known gap: a content-width change that alters neither `tabs` nor the box's + * outer size — a live `renderTabTitle` growing a chip, or a drop-caret slot + * mounting mid-drag — keeps the fade at its last reading until the next + * scroll or resize. The fade is orientation chrome, so a stale edge fades a + * few frames late rather than hiding anything. + */ +function useStripScrollFades(box: RefObject, tabs: readonly TabId[]): void { + useLayoutEffect(() => { + const element = box.current + /* v8 ignore next -- the box is rendered unconditionally with the strip. */ + if (element === null) return undefined + const apply = (): void => { + const sides = hiddenSides(element) + if (sides === undefined) delete element.dataset.dockkitStripScroll + else element.dataset.dockkitStripScroll = sides + } + apply() + element.addEventListener('scroll', apply, { passive: true }) + const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(apply) + observer?.observe(element) + return () => { + element.removeEventListener('scroll', apply) + observer?.disconnect() + } + }, [box, tabs]) +} + +/** + * Bring the active chip into the chip box's view whenever the active tab or + * the row of chips changes: a tab opened or selected past the box's edge, or + * moved there by a close or a reorder, scrolls the box to it, with the fade + * band (24px) cleared so the chip is not under it. A chip already in view + * moves nothing. Direct DOM, like the fades above: the box's scroll position + * renders nothing. + */ +function useActiveChipInView( + box: RefObject, + chips: ReadonlyMap, + tabs: readonly TabId[], + activeTabId: TabId | undefined, +): void { + useLayoutEffect(() => { + const element = box.current + const chip = activeTabId === undefined ? undefined : chips.get(activeTabId) + /* v8 ignore next -- the box and the active tab's chip are rendered with the strip. */ + if (element === null || chip === undefined) return + const bounds = element.getBoundingClientRect() + const rect = chip.getBoundingClientRect() + if (rect.left < bounds.left) element.scrollLeft += rect.left - bounds.left - STRIP_FADE + else if (rect.right > bounds.right) element.scrollLeft += rect.right - bounds.right + STRIP_FADE + }, [box, chips, tabs, activeTabId]) +} + +/** Width of the chip box's fade at a hidden side; mirrors the stylesheet's 24px. */ +const STRIP_FADE = 24 + +/** Why the split control cannot act right now. */ +function splitBlockedTitle(labels: PaneCallbacks['labels'], block: SplitBlock): string { switch (block) { - case undefined: return labels.splitPane case 'budget': return labels.splitPaneDisabled case 'width': return labels.splitPaneNarrow } @@ -94,6 +214,9 @@ export function TabPanel({ state, pane, callbacks }: TabPanelProps): ReactNode { const [menu, setMenu] = useState<{ readonly tabId: TabId; readonly anchor: HTMLElement } | undefined>(undefined) // The mounted chips by tab, for the keys that move focus between them. const [chips] = useState(() => new Map()) + const stripTabs = useRef(null) + useStripScrollFades(stripTabs, pane.tabs) + useActiveChipInView(stripTabs, chips, pane.tabs, pane.activeTabId) const active = pane.activeTabId === undefined ? undefined : getTab(state, pane.activeTabId) const block = callbacks.splitBlock(pane.id) const target = callbacks.dropTarget @@ -132,14 +255,22 @@ export function TabPanel({ state, pane, callbacks }: TabPanelProps): ReactNode { }} >
-
+
{pane.tabs.map((tabId, index) => { const tab = getTab(state, tabId) const selected = tabId === pane.activeTabId - const canClose = callbacks.canCloseTab(tabId) + const closable = callbacks.canCloseTab(tabId) + // A pane's lone unclosable chip is a label, not a choice: there is + // no other tab to select against and nothing to do to it. + const quiet = !closable && pane.tabs.length === 1 return ( - {stripIndex === index &&
} + {(index > 0 || stripIndex === index) && ( +
+ )}
{ if (element === null) chips.delete(tabId) else chips.set(tabId, element) @@ -188,8 +321,8 @@ export function TabPanel({ state, pane, callbacks }: TabPanelProps): ReactNode { setMenu(current => current?.tabId === tabId ? undefined : { tabId, anchor }) }} > - {callbacks.renderTabTitle?.(tab) ?? tab.title} - {canClose && ( + {callbacks.renderTabTitle?.(tab) ?? tab.title} + {closable && ( )} {menu?.tabId === tabId && ( { setMenu(undefined); callbacks.onCloseTab(tabId) }} + onClose={closable ? () => { setMenu(undefined); callbacks.onCloseTab(tabId) } : undefined} onDismiss={() => { setMenu(undefined) }} extras={callbacks.renderTabMenuItems?.(tab, () => { setMenu(undefined) })} /> @@ -220,40 +352,45 @@ export function TabPanel({ state, pane, callbacks }: TabPanelProps): ReactNode { ) })} - {stripIndex === pane.tabs.length &&
} + {stripIndex === pane.tabs.length &&
}
{callbacks.canAddTab(pane.id) && ( - + + + )}
- {!(callbacks.hideSplitAtCapacity && block === 'budget') && ( - + {!(callbacks.hideSplitWhenBlocked && block !== undefined) && ( + + + )} {/* The embedder's surface-wide controls, in the top-right pane only: the strip is the surface's top edge, and this pane's end is its corner. */} @@ -273,12 +410,17 @@ export function TabPanel({ state, pane, callbacks }: TabPanelProps): ReactNode { {active === undefined ?

{callbacks.labels.emptyPane}

: callbacks.renderTab(active)} - {zone !== undefined && (callbacks.horizontalDrops && zone !== 'center' - ? <> -
-
+ {zone !== undefined && ( + <> +
+ {callbacks.horizontalDrops && zone !== 'center' + ? <> + + + + : } - :
)} + )}
) diff --git a/packages/client/ui-dockkit/src/components/TabTitle.tsx b/packages/client/ui-dockkit/src/components/TabTitle.tsx new file mode 100644 index 0000000000..679a27da02 --- /dev/null +++ b/packages/client/ui-dockkit/src/components/TabTitle.tsx @@ -0,0 +1,37 @@ +/** + * A chip's title: one line, clipped at the chip's inset, never ellipsized. + * While the text is wider than its box the span carries + * `data-dockkit-tab-clipped`, and the stylesheet fades the text out at the + * clipped edge in place of an ellipsis. Written to the DOM directly rather + * than through state: a reading changes nothing that renders, only how the + * stylesheet paints it. Re-read after every commit (the text may have + * changed) and whenever the span's box resizes (the chip shrank or grew). + */ +import { useLayoutEffect, useRef } from 'react' +import type { ReactNode } from 'react' +import css from './dockkit.module.css' + +/** Set or clear the span's `data-dockkit-tab-clipped` from its current geometry. */ +function markClipped(element: HTMLElement): void { + // Sub-pixel widths: the text counts as clipped past one whole pixel. + if (element.scrollWidth > element.clientWidth + 1) element.dataset.dockkitTabClipped = '' + else delete element.dataset.dockkitTabClipped +} + +/** The title span of a strip chip or a floating panel's header chip. */ +export function TabTitle({ children }: { readonly children: ReactNode }): ReactNode { + const span = useRef(null) + useLayoutEffect(() => { + /* v8 ignore next -- the span is rendered unconditionally. */ + if (span.current !== null) markClipped(span.current) + }) + useLayoutEffect(() => { + const element = span.current + /* v8 ignore next -- the span is rendered unconditionally. */ + if (element === null || typeof ResizeObserver === 'undefined') return undefined + const observer = new ResizeObserver(() => { markClipped(element) }) + observer.observe(element) + return () => { observer.disconnect() } + }, []) + return {children} +} diff --git a/packages/client/ui-dockkit/src/components/dockkit.module.css b/packages/client/ui-dockkit/src/components/dockkit.module.css index 58907277e8..972ec3b951 100644 --- a/packages/client/ui-dockkit/src/components/dockkit.module.css +++ b/packages/client/ui-dockkit/src/components/dockkit.module.css @@ -7,10 +7,11 @@ * Colours come from the embedder's token layer; the kit names no literal. Type * follows the embedder's content axis (`--dsh-content-font-size` and its * secondary step) so a surface reads at the same size as the page around it. - * Emphasis — a hovered divider, the drop caret, the drop-zone hint — takes the - * platform's accent (`--dsw-alias-brand-primary-new-colorprimary-new-color`), - * not `--dsw-alias-brand-primary`, which this platform binds to its - * near-black (light) or near-white (dark) foreground. + * Emphasis — the drop caret, the drop-zone hint — takes the platform's accent + * (`--dsw-alias-brand-primary-new-colorprimary-new-color`), not + * `--dsw-alias-brand-primary`, which this platform binds to its near-black + * (light) or near-white (dark) foreground. A hovered divider takes the + * caption label ink instead, reading as a handle rather than a highlight. */ .split { @@ -35,25 +36,87 @@ min-height: 0; } +/* The divider owns no layout room (`SPLIT_MINIMUMS.divider` mirrors the 0): + the halves abut, so a rule a pane draws across its own edge — a header's + hairline — runs unbroken past the seam. The visible rule is a 0.5px hairline + centred on the seam, matching the embedder's other borders, and `::after` + widens the pointer target to 8px by reaching 4px over each neighbour. + `z-index` keeps that overhang above the later sibling, which would otherwise + take the hit. */ .divider { position: relative; + z-index: 1; flex: none; - background: var(--dsw-alias-border-l1); touch-action: none; } +.divider::before, +.divider::after { + content: ''; + position: absolute; +} + +.divider::before { + background: var(--dsw-alias-border-l4); +} + .splitRow > .divider { - width: 4px; + width: 0; cursor: col-resize; } +.splitRow > .divider::before { + top: 0; + bottom: 0; + left: -0.25px; + width: 0.5px; +} + +.splitRow > .divider::after { + top: 0; + bottom: 0; + left: -4px; + right: -4px; +} + .splitColumn > .divider { - height: 4px; + height: 0; cursor: row-resize; } -.divider:hover { - background: var(--dsw-alias-brand-primary-new-colorprimary-new-color); +.splitColumn > .divider::before { + left: 0; + right: 0; + top: -0.25px; + height: 0.5px; +} + +.splitColumn > .divider::after { + left: 0; + right: 0; + top: -4px; + bottom: -4px; +} + +/* Hover: a 1px grip fades in over the hairline, its caption ink deepest at + its middle and fading toward both ends. The grip is painted on the `::after` + hit target, centred on the seam, and crossfaded through opacity, because a + gradient cannot transition from the hairline's solid colour. */ +.divider::after { + opacity: 0; + transition: opacity 120ms ease-out; +} + +.divider:hover::after { + opacity: 1; +} + +.splitRow > .divider::after { + background: linear-gradient(to bottom, transparent, var(--dsw-alias-label-caption) 50%, transparent) center / 1px 100% no-repeat; +} + +.splitColumn > .divider::after { + background: linear-gradient(to right, transparent, var(--dsw-alias-label-caption) 50%, transparent) center / 100% 1px no-repeat; } /* Both floors: a flex item's minimum is its content's, and a body's longest @@ -74,17 +137,18 @@ min-width: 0; min-height: 0; overflow: hidden; - border: 1px solid transparent; } -.pane[data-dockkit-pane-active] { - border-color: var(--dsw-alias-border-l2); -} - -/* One centre line for everything in the strip: every child — chip, add - control, split control, the embedder's chrome — is 24px tall, and the strip - centres them, so chip text and control glyphs never sit at different heights. - A child with another height would break that; keep them at 24px. +/* A 38px row whose 28px content band sits at its bottom: chips fill the band, + so they end flush with the strip's bottom edge, and the 28px add control, the + 28px split control, and the embedder's 28px chrome centre on the band's one + centre line, so chip text and control glyphs never sit at different heights. + The 10px above is the strip's own top margin, inside its box so the hit + area stays one element; the 10px at the start is the first chip's inset + from the edge, and the 6px at the end puts the last control's glyph 12px + from the edge. The strip draws no border: a body + that wants a rule under it draws its own. A floating panel's header is + this same row. The strip never clips: the chip box below is its one shrinking part, and every control after it is `flex: none`, so a narrow pane costs chips, never @@ -94,27 +158,47 @@ flex: none; gap: 4px; align-items: center; - height: 36px; - padding: 0 6px; - border-bottom: 0.5px solid var(--dsw-alias-border-l1); + height: 28px; + padding: 10px 6px 0 10px; touch-action: none; } -/* The chips. Shrinks to nothing before any control after it moves; what no - longer fits is clipped here. Deliberately not a scroller: a horizontal - scroll container claims a press-and-move as its own gesture and cancels the - pointer, which would abandon every tab drag in a narrow pane. Tabs shrink - and ellipsize first. */ +/* The chips. Shrinks to nothing before any control after it moves; chips keep + their 80px floor, so what no longer fits scrolls here on the wheel, with no + scrollbar drawn. `touch-action: none` keeps a touch press-and-move a tab drag + rather than a pan the scroller would claim and cancel the pointer for. + `data-dockkit-strip-scroll` names the hidden sides (`start`, `end`, or + both); each hidden side fades the chips out over 24px into whatever ground + the pane draws, so the row reads as continuing under the controls. */ .stripTabs { display: flex; flex: 0 1 auto; - gap: 4px; align-items: center; min-width: 0; - overflow: hidden; + overflow-x: auto; + overflow-y: hidden; + /* The scroll to a newly active chip glides rather than jumps. */ + scroll-behavior: smooth; + scrollbar-width: none; touch-action: none; } +.stripTabs::-webkit-scrollbar { + display: none; +} + +.stripTabs[data-dockkit-strip-scroll='end'] { + mask-image: linear-gradient(to right, black calc(100% - 24px), transparent); +} + +.stripTabs[data-dockkit-strip-scroll='start'] { + mask-image: linear-gradient(to right, transparent, black 24px); +} + +.stripTabs[data-dockkit-strip-scroll='start end'] { + mask-image: linear-gradient(to right, transparent, black 24px, black calc(100% - 24px), transparent); +} + /* Takes the free space and gives it all back first: a zero basis shrinks nothing, so shortage lands on the chip box alone. */ .stripFill { @@ -122,37 +206,64 @@ min-width: 0; } -/* Embedder controls at the strip's end, set off from the kit's own split - control by a hairline so the two groups read as two groups. */ +/* Embedder controls at the strip's end, spaced from the kit's own split + control as they are from each other: the strip's 4px gap plus this 4px + margin equals the 8px between the controls. */ .stripChrome { display: flex; flex: none; - gap: 2px; + gap: 8px; align-items: center; - height: 24px; - margin-left: 2px; - padding-left: 4px; - border-left: 0.5px solid var(--dsw-alias-border-l1); + height: 28px; + margin-left: 4px; } -.caret { +/* The slot between two chips: a 10px box with a hairline down its centre. + Targeted by a drag, the same box draws the caret instead, so the chips + around it never move. */ +.slot { + display: flex; flex: none; - align-self: center; + align-items: center; + justify-content: center; + width: 10px; + height: 28px; +} + +.slot::before { + content: ''; + width: 0.5px; + height: 14px; + background: var(--dsw-alias-border-l4); +} + +/* The active chip is a filled capsule and needs no rule against it: the two + slots beside it go blank, so the row reads as the capsule between bare + chips. A caret in either slot still draws. */ +.tabActive + .slot:not(.slotCaret)::before, +.slot:not(.slotCaret):has(+ .tabActive)::before { + background: transparent; +} + +.slotCaret::before { width: 2px; height: 20px; background: var(--dsw-alias-brand-primary-new-colorprimary-new-color); } +/* The chip: a 28px capsule whose title runs to the right inset. The close + control is not in the flow — it sits over the title's last 14px and shows + while the chip is active, hovered, or holds focus — so a chip is the same + width with and without it and nothing shifts on hover. */ .tab { position: relative; display: flex; flex: 0 1 auto; - gap: 4px; align-items: center; - min-width: 44px; + min-width: 80px; max-width: 170px; - height: 24px; - padding: 0 5px 0 10px; + height: 28px; + padding: 0 10px; color: var(--dsw-alias-label-secondary); font-size: var(--dsh-content-font-size-secondary, 13px); line-height: 1; @@ -163,35 +274,82 @@ user-select: none; } +/* A flex row with a 5px gap, so an embedder's leading glyph (`renderTabTitle`) + centres on the text's line rather than sitting on its baseline and keeps + one distance from it. The clip is horizontal in intent; the line box must + hold the descenders it would otherwise cut, hence a line-height above the + chip's own 1. No ellipsis: a title wider than its box (`TabTitle` sets + `data-dockkit-tab-clipped`) fades out over its last 16px instead. A mask on + the title rather than a painted gradient: the chip's fill is bare, + translucent hover, or the active tag colour, and a mask matches every one + without naming it. */ .tabTitle { + display: flex; + flex: 1 1 auto; + gap: 5px; + align-items: center; + min-width: 0; overflow: hidden; - text-overflow: ellipsis; + line-height: 1.4; } +.tabTitle[data-dockkit-tab-clipped] { + mask-image: linear-gradient(to right, black calc(100% - 16px), transparent); +} + +/* While the close shows, the title is gone under the close's circle and fades + out over the 16px before it: the circle spans the chip's last 24px and the + title ends 10px inside the chip, so the title's last 14px are under it. + Later than the clipped mask, so a clipped chip showing its close fades + under the close. A floating panel's header chip has no close, so it keeps + the clipped mask alone. */ +.tab:not(.floatTitle):hover .tabTitle, +.tab:not(.floatTitle):focus-within .tabTitle, +.tabActive .tabTitle { + mask-image: linear-gradient(to right, black calc(100% - 30px), transparent calc(100% - 14px)); +} + +/* A 20px icon button inset 4px from the chip's end, holding a 14px glyph in + the tertiary ink, so it sits back from the title beside it. */ .tabClose { + position: absolute; + top: 4px; + right: 4px; display: flex; - flex: none; align-items: center; justify-content: center; - width: 16px; - height: 16px; + width: 20px; + height: 20px; padding: 0; - color: inherit; + color: var(--dsw-alias-label-tertiary); line-height: 1; background: transparent; border: none; - border-radius: 50%; - corner-shape: round; + border-radius: 20px; cursor: pointer; + opacity: 0; + /* Hidden means untouchable too: without this a touch press on the chip's + trailing 20px would close the tab instead of activating it. */ + pointer-events: none; } +/* Shown while the chip is hovered or holds focus, and always on the active + chip: the tab in view is the one a reader closes next. */ +.tab:hover .tabClose, +.tab:focus-within .tabClose, +.tabActive .tabClose { + opacity: 1; + pointer-events: auto; +} + +/* The chip's height and corner, so it reads as one more capsule in the row. */ .addTab { display: flex; flex: none; align-items: center; justify-content: center; - width: 24px; - height: 24px; + width: 28px; + height: 28px; padding: 0; color: var(--dsw-alias-label-secondary); line-height: 1; @@ -208,46 +366,72 @@ .tabClose:hover { color: var(--dsw-alias-label-primary); - background: var(--dsw-alias-interactive-bg-hover-solid); + background: var(--dsw-alias-interactive-bg-hover); } .tab:hover { background: var(--dsw-alias-interactive-bg-hover); } -/* The active chip is the filled capsule; the rest are bare text. */ +/* The active chip is the filled capsule, in the tag fill; the rest are bare text. */ .tabActive { color: var(--dsw-alias-label-primary); - background: var(--dsw-alias-interactive-bg-active); + background: var(--dsw-alias-markdown-tag); +} + +/* A pane's lone unclosable chip is a label, not a choice: no capsule, no + hover fill — the primary ink on the pane's own ground. Its title never + yields to a close control, so it keeps the clipped fade alone. */ +.tab.tabQuiet, +.tab.tabQuiet:hover { + color: var(--dsw-alias-label-primary); + background: transparent; + cursor: default; +} + +.tab.tabQuiet .tabTitle, +.tab.tabQuiet:hover .tabTitle, +.tab.tabQuiet:focus-within .tabTitle { + mask-image: none; +} + +.tab.tabQuiet .tabTitle[data-dockkit-tab-clipped] { + mask-image: linear-gradient(to right, black calc(100% - 16px), transparent); } .tabDragging { opacity: 0.5; } +/* The geometry of a message's action row button (ui-chat MessageIconActions): + a 28px circle around a 15px glyph. */ .iconButton { - display: flex; + display: inline-flex; flex: none; align-items: center; justify-content: center; - width: 24px; - height: 24px; - padding: 0; + width: 28px; + height: 28px; + padding: 6px; color: var(--dsw-alias-label-secondary); line-height: 1; background: transparent; border: none; - border-radius: 4px; + border-radius: 28px; cursor: pointer; } +.iconButton svg { + width: 15px; + height: 15px; +} + .iconButton:hover:not(:disabled) { - color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); } .iconButton:disabled { - color: var(--dsw-alias-label-tertiary); + opacity: 0.4; cursor: default; } @@ -288,12 +472,14 @@ background: var(--dsw-alias-interactive-bg-hover); } +/* No padding of its own: a tab's body reaches the pane's edges and the strip's + bottom edge, and keeps its own insets, so a header row it draws sits flush + under the strip. */ .paneBody { position: relative; flex: 1 1 auto; min-width: 0; min-height: 0; - padding: 12px; overflow: auto; /* This sheet draws elevated surfaces (the strip and the menu), so a scroller inside it rebinds the thumb indirection in a complete pair — a base-surface thumb on an elevated ground reads as a smudge. */ @@ -303,14 +489,40 @@ .empty { margin: 0; + padding: 12px; color: var(--dsw-alias-label-tertiary); font-size: var(--dsh-content-font-size-secondary, 13px); } +/* The drop hint is a scrim and, over it, one box pair per landing region. + The scrim (`.dockScrim`) covers the whole body with the translucent, blurred + ground, so the insets between and around the cards dim the body's content + instead of letting it show through raw. The outer box (`.dockHint`) is the + region a release fills — the whole body, or 40% (edge bands) or 50% + (horizontal halves) of it — and draws nothing; it is a padded frame that + keeps the card inside it 8px clear of the body's edges and, between two + horizontal halves, 4px clear of the seam, so neighbouring cards sit 8px + apart like the body's own insets. The card (`.dockHintCard`) is what the eye + lands on: a dashed frame with the zone's glyph and caption stacked at its + centre. A card that is not under the pointer stays as a quiet outline so the + reader sees where the other release would land. */ +.dockScrim { + position: absolute; + inset: 0; + pointer-events: none; + /* The ground the panes sit on, not a raised layer: in dark mode `bg-layer-2` + is a lighter bluish step than the column's `bg-base`, which tinted the + whole scrim away from the content beneath it. */ + background: color-mix(in srgb, var(--dsw-alias-bg-base) 72%, transparent); + backdrop-filter: blur(6px); + animation: dockScrimIn 140ms ease-out; +} + .dockHint { position: absolute; - background: var(--dsw-alias-bg-multi-select); - border: 1px solid var(--dsw-alias-brand-primary-new-colorprimary-new-color); + display: flex; + box-sizing: border-box; + padding: 8px; pointer-events: none; } @@ -332,15 +544,82 @@ width: 40%; } -[data-dockkit-drop-zones='horizontal'] .dockHint[data-dockkit-dock-zone='left'], -[data-dockkit-drop-zones='horizontal'] .dockHint[data-dockkit-dock-zone='right'] { +[data-dockkit-drop-zones='horizontal'] .dockHint[data-dockkit-dock-zone='left'] { width: 50%; + padding-right: 4px; } -[data-dockkit-drop-zones='horizontal'] .dockHint[data-dockkit-dock-zone='left']:not([data-dockkit-drop-active]), -[data-dockkit-drop-zones='horizontal'] .dockHint[data-dockkit-dock-zone='right']:not([data-dockkit-drop-active]) { - background: transparent; - border-color: var(--dsw-alias-border-l2); +[data-dockkit-drop-zones='horizontal'] .dockHint[data-dockkit-dock-zone='right'] { + width: 50%; + padding-left: 4px; +} + +.dockHintCard { + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 8px; + align-items: center; + justify-content: center; + min-width: 0; + min-height: 0; + padding: 12px; + overflow: hidden; + color: var(--dsw-alias-label-tertiary); + border: 1.5px dashed var(--dsw-alias-border-l2); + border-radius: 12px; + transition: color 120ms ease-out, background-color 120ms ease-out, border-color 120ms ease-out; + animation: dockHintIn 140ms ease-out; +} + +/* The card under the pointer takes the accent: a tinted ground over the scrim, + an accent dashed frame, and secondary ink for the glyph and caption. */ +.dockHint[data-dockkit-drop-active] .dockHintCard { + color: var(--dsw-alias-label-secondary); + background: color-mix(in srgb, var(--dsw-alias-brand-primary-new-colorprimary-new-color) 8%, transparent); + border-color: var(--dsw-alias-brand-primary-new-colorprimary-new-color); +} + +.dockHintCard svg { + flex: none; + width: 20px; + height: 20px; +} + +/* One line, clipped rather than wrapped: the card in a narrow half is still + wide enough for its glyph, and the caption fades under the frame. */ +.dockHintLabel { + max-width: 100%; + overflow: hidden; + font-size: var(--dsh-content-font-size-secondary, 13px); + font-weight: 500; + line-height: 1.4; + white-space: nowrap; + text-overflow: ellipsis; +} + +@keyframes dockHintIn { + from { + opacity: 0; + transform: scale(0.98); + } + + to { + opacity: 1; + transform: none; + } +} + +/* The scrim fades in without the cards' scale: a full-body cover that shrinks + would expose an uncovered rim of raw content on entry. */ +@keyframes dockScrimIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } } .dockHint[data-dockkit-dock-zone='top'] { @@ -361,43 +640,50 @@ position: fixed; display: flex; flex-direction: column; - background: var(--dsw-alias-bg-layer-1); - border: 0.5px solid var(--dsw-alias-border-l2); - border-radius: 8px; - box-shadow: 0 8px 24px var(--dsw-alias-bg-mask-drop); + /* The body inside is unpadded and reaches the edges, so the frame clips it + to its own corners. */ + overflow: hidden; + background: var(--dsw-alias-bg-layer-2); + border-radius: 20px; + /* The menu's shadow with the stroke rebound one step lighter than the + default (l2): the panel reads as the same kind of raised surface, and the + shadow's hairline outlines it, so the frame draws no border of its own. */ + --dsw-elevation-stroke-color: var(--dsw-alias-border-l2); + box-shadow: var(--dsw-elevation-prominent); pointer-events: auto; } -/* The active panel keeps the same hairline: it is already on top of the z - order and casts the same shadow, and a heavier or darker frame read as a - defect. `data-dockkit-float-active` stays on the element for tests. */ +/* The active panel keeps the same frame: it is already on top of the z order + and casts the same shadow, and a heavier or darker frame read as a defect. + `data-dockkit-float-active` stays on the element for tests. */ +/* The panel's header is the pane strip's row (`.tabStrip` supplies the + metrics) and the whole of it is the move grip. */ .floatHeader { - display: flex; - flex: none; - gap: 2px; - align-items: center; - height: 28px; - padding: 0 4px 0 10px; - border-bottom: 0.5px solid var(--dsw-alias-border-l1); cursor: move; - touch-action: none; user-select: none; } +/* The one tab, drawn as its chip would be in a strip but never selected, + hovered, or closable from here: the frame's controls do that. */ .floatTitle { - flex: 1 1 auto; - overflow: hidden; - color: var(--dsw-alias-label-primary); - font-size: var(--dsh-content-font-size-secondary, 13px); - white-space: nowrap; - text-overflow: ellipsis; + flex: 0 1 auto; + cursor: inherit; } +.tab.floatTitle:hover { + background: transparent; +} + +/* Mirrored so the filled pane sits at the right, where the docked surface is. */ +.dockGlyph { + transform: scaleX(-1); +} + +/* Unpadded like `.paneBody`: the same body draws the same insets in a float. */ .floatBody { flex: 1 1 auto; min-height: 0; - padding: 10px; overflow: auto; /* This sheet draws elevated surfaces (the strip and the menu), so a scroller inside it rebinds the thumb indirection in a complete pair — a base-surface thumb on an elevated ground reads as a smudge. */ @@ -409,19 +695,35 @@ position: absolute; right: 0; bottom: 0; - width: 14px; - height: 14px; + width: 20px; + height: 20px; cursor: nwse-resize; touch-action: none; } +/* The grip is a quarter arc concentric with the frame's 20px corner (16px + radius at a 4px inset), so it reads as part of the frame. It shows only + while the pointer is over the panel — an idle panel keeps a clean corner — + and `:active` keeps it lit while a drag holds the pointer capture. */ .floatResize::after { - position: absolute; - right: 3px; - bottom: 3px; - width: 6px; - height: 6px; - border-right: 2px solid var(--dsw-alias-label-tertiary); - border-bottom: 2px solid var(--dsw-alias-label-tertiary); content: ''; + position: absolute; + right: 4px; + bottom: 4px; + width: 16px; + height: 16px; + border-right: 1.5px solid var(--dsw-alias-label-caption); + border-bottom: 1.5px solid var(--dsw-alias-label-caption); + border-bottom-right-radius: 16px; + opacity: 0; + transition: opacity 120ms ease-out; +} + +.float:hover .floatResize::after, +.floatResize:active::after { + opacity: 1; +} + +.floatResize:hover::after { + border-color: var(--dsw-alias-label-tertiary); } diff --git a/packages/client/ui-dockkit/src/components/measure.ts b/packages/client/ui-dockkit/src/components/measure.ts index 6ce1fea4eb..c03961ba80 100644 --- a/packages/client/ui-dockkit/src/components/measure.ts +++ b/packages/client/ui-dockkit/src/components/measure.ts @@ -63,12 +63,31 @@ function dividerSize(root: HTMLElement): number { return thickness > 0 ? thickness : SPLIT_MINIMUMS.divider } +/** + * The rendered split control's footprint in the strip's fixed part: its box + * plus the strip's own gap, both of which the strip sheds when the control + * hides. 0 while the control is hidden or unmeasured. + */ +function splitControlFootprint(pane: HTMLElement): number { + const control = pane.querySelector('[data-dockkit-split-button]') + if (control === null) return 0 + const width = control.getBoundingClientRect().width + if (!(width > 0)) return 0 + const strip = pane.querySelector('[data-dockkit-strip]') + /* v8 ignore next -- the control only renders inside a strip. */ + return width + (strip === null ? 0 : px(getComputedStyle(strip).columnGap)) +} + /** * Measure every docked pane under `root`. * @param root - the docked surface's element. + * @param splitHiddenWhenBlocked - whether the embedder hides blocked split + * controls (`hideSplitWhenBlocked`); the room rule then leaves the control's + * footprint out of each strip's fixed part, so the reading cannot flip with + * the control's visibility (see `PaneMeasure.splitControlWidth`). * @returns each pane's fit, keyed by pane id. */ -export function measurePaneFits(root: HTMLElement): ReadonlyMap { +export function measurePaneFits(root: HTMLElement, splitHiddenWhenBlocked = false): ReadonlyMap { const minimums: SplitMinimums = { divider: dividerSize(root), chip: chipMinimum(root), body: SPLIT_MINIMUMS.body } const fits = new Map() for (const [paneId, pane] of paneElements(root)) { @@ -77,6 +96,7 @@ export function measurePaneFits(root: HTMLElement): ReadonlyMap) => void /** Why a pane cannot split right now, or `undefined` while it can. */ readonly splitBlock: (paneId: PaneId) => SplitBlock | undefined - /** Hide budget-blocked split controls without hiding width-blocked controls. */ - readonly hideSplitAtCapacity?: boolean + /** Hide blocked split controls instead of rendering them disabled. */ + readonly hideSplitWhenBlocked?: boolean /** Whether a pane's strip draws the add control. */ readonly canAddTab: (paneId: PaneId) => boolean - /** Whether a tab's chip and menu offer close. */ + /** Whether a tab draws its close control and its menu's close item. */ readonly canCloseTab: (tabId: TabId) => boolean /** Live drop preview, or `undefined` while nothing is being dragged. */ readonly dropTarget: DropTarget | undefined diff --git a/packages/client/ui-dockkit/src/contract/adapter.ts b/packages/client/ui-dockkit/src/contract/adapter.ts index 3ca282dd3e..81239e816e 100644 --- a/packages/client/ui-dockkit/src/contract/adapter.ts +++ b/packages/client/ui-dockkit/src/contract/adapter.ts @@ -31,6 +31,8 @@ export interface DockLabels { readonly dockFloat: string /** Close a floating panel. */ readonly closeFloat: string + /** The drop hint's caption for each body zone a dragged tab can land on. */ + readonly dropZone: Readonly> } /** @@ -45,7 +47,9 @@ export type TabRenderer = (tab: TabRecord) => ReactNode * * The kit's own item is the close gesture; anything that means something about * the tab's content comes from here. An item that acts MUST call `dismiss`, - * because the menu closes on its own items only. + * because the menu closes on its own items only. Every rendered item MUST + * carry `role="menuitem"`: the kit probes for that role to dismiss a menu + * that would paint empty, so items without it count as an empty menu. * @param tab - the tab whose menu is open. * @param dismiss - close the menu without acting. * @returns extra actions with ARIA menuitem, menuitemcheckbox, or menuitemradio roles, or nothing. diff --git a/packages/client/ui-dockkit/src/engine/geometry.ts b/packages/client/ui-dockkit/src/engine/geometry.ts index ff32cd3208..973dd47be3 100644 --- a/packages/client/ui-dockkit/src/engine/geometry.ts +++ b/packages/client/ui-dockkit/src/engine/geometry.ts @@ -71,6 +71,17 @@ export interface PaneMeasure { readonly chipsWidth: number /** Width of the fill: free space, not a control. */ readonly fillWidth: number + /** + * Footprint of the rendered split control (its width plus the strip's gap) + * for embedders that hide blocked split controls: a half too narrow to + * split hides its own control, so the rule leaves the footprint out of the + * fixed part. Leaving it out is also what keeps the reading stable — the + * control hiding sheds the same footprint from the strip, and a reading + * that counted it would flip with the control's visibility and re-render + * forever. Absent or 0 keeps the control in the fixed part, for embedders + * that render a blocked control disabled. + */ + readonly splitControlWidth?: number } /** Pixel minimums the room rule holds each half to. */ @@ -79,18 +90,19 @@ export interface SplitMinimums { readonly divider: number /** One chip at its minimum: the smallest strip that still names a tab. */ readonly chip: number - /** The smallest body under a strip: one secondary text line inside the body's padding. */ + /** The smallest body under a strip: one secondary text line inside 12px of the body's own insets. */ readonly body: number } /** * The minimums where no computed style can be read, mirroring - * `dockkit.module.css`: `.splitRow > .divider` is 4px wide; `.tab` is 44px of - * content plus 10px + 5px of padding (content-box), 59px; the body's 12px - * padding above and below one 13px secondary line at 1.6 line-height is 45px, - * held to 48px. + * `dockkit.module.css`: `.splitRow > .divider` takes no layout width (its + * hairline is painted over the seam); `.tab` is 80px of content plus + * 10px + 10px of padding (content-box), 100px; 12px above and below one 13px + * secondary line at 1.6 line-height — the inset a body draws for itself, as + * `.empty` does — is 45px, held to 48px. */ -export const SPLIT_MINIMUMS: SplitMinimums = { divider: 4, chip: 59, body: 48 } +export const SPLIT_MINIMUMS: SplitMinimums = { divider: 0, chip: 100, body: 48 } /** Whether a pane's two halves after an equal split would each still work. */ export interface HalvesFit { @@ -102,9 +114,10 @@ export interface HalvesFit { /** * The room rule. After an equal split each half must hold what cannot shrink: - * horizontally the strip's fixed part — its width minus the chip box and the - * fill, which is the padding, the gaps, and every control that pane draws — - * plus one chip at its minimum; vertically the strip plus a minimum body. The + * horizontally the strip's fixed part — its width minus the chip box, the + * fill, and `splitControlWidth`, which is the padding, the gaps, and every + * control a half would still draw — plus one chip at its minimum; vertically + * the strip plus a minimum body. The * borders are what the pane's box exceeds the strip's by. An unmeasured pane * (no layout, as under jsdom) fits: the rule only blocks on a positive reading. * @param measure - the pane's rectangles. @@ -115,7 +128,7 @@ export function halvesFit(measure: PaneMeasure, minimums: SplitMinimums = SPLIT_ const { pane, strip } = measure if (!(pane.width > 0) || !(pane.height > 0) || !(strip.width > 0)) return { row: true, column: true } const borders = Math.max(0, pane.width - strip.width) - const fixed = Math.max(0, strip.width - measure.chipsWidth - measure.fillWidth) + const fixed = Math.max(0, strip.width - measure.chipsWidth - measure.fillWidth - (measure.splitControlWidth ?? 0)) const halfWidth = (pane.width - minimums.divider) / 2 - borders const halfHeight = (pane.height - minimums.divider) / 2 - borders return { diff --git a/packages/client/ui-dockkit/src/engine/planner.ts b/packages/client/ui-dockkit/src/engine/planner.ts index 1c61ab94c2..1657537144 100644 --- a/packages/client/ui-dockkit/src/engine/planner.ts +++ b/packages/client/ui-dockkit/src/engine/planner.ts @@ -255,13 +255,16 @@ export function planPlaceTab( /** * Resolve a tab release on a pane body: the centre moves the tab in, an edge * splits the pane and seats the tab in the new half. A pane's only tab released - * on that pane changes nothing in either zone: the split would empty the pane - * and seat the tab beside where it already was. + * on that pane's centre changes nothing; released on its edge it splits, and + * the factory's tab backfills the pane the drag would otherwise empty — without + * a factory that release also changes nothing, since the split would empty the + * pane and seat the tab beside where it already was. * @param state - current layout. * @param mint - id source for a pane an edge release creates. * @param tabId - the dragged tab. * @param targetPaneId - pane under the pointer. * @param zone - dock region the pointer released in. + * @param makeTab - builds the tab that backfills a pane its only tab splits away from. * @returns the operations, or none when the release changes nothing. */ export function planDropTab( @@ -270,6 +273,7 @@ export function planDropTab( tabId: TabId, targetPaneId: PaneId, zone: DockZone, + makeTab?: TabFactory, ): readonly LayoutOp[] { const source = findTabPane(state, tabId) const target = getPane(state, targetPaneId) @@ -281,20 +285,25 @@ export function planDropTab( return [tabInto(source, tabId, targetPaneId, target.tabs.length)] } - if (source.id === targetPaneId && source.tabs.length === 1) return NOTHING + const vacates = source.id === targetPaneId && source.tabs.length === 1 + if (vacates && makeTab === undefined) return NOTHING if (!canSplit(state)) return NOTHING const newPaneId = mint('pane') - return [ - { - type: 'split', - paneId: targetPaneId, - axis: split.axis, - direction: split.direction, - newPaneId, - newSplitId: mint('split'), - }, - tabInto(source, tabId, newPaneId, 0), - ] + const ops: LayoutOp[] = [{ + type: 'split', + paneId: targetPaneId, + axis: split.axis, + direction: split.direction, + newPaneId, + newSplitId: mint('split'), + }] + // The backfill seats before the move so the moved tab ends focused, as any + // other drop leaves it. + if (vacates && makeTab !== undefined) { + ops.push({ type: 'openTab', paneId: targetPaneId, tab: makeTab(mint('tab')), index: source.tabs.length }) + } + ops.push(tabInto(source, tabId, newPaneId, 0)) + return ops } /** diff --git a/packages/client/ui-dockkit/tests/components.client.spec.tsx b/packages/client/ui-dockkit/tests/components.client.spec.tsx index 23b49d2237..85768ebb55 100644 --- a/packages/client/ui-dockkit/tests/components.client.spec.tsx +++ b/packages/client/ui-dockkit/tests/components.client.spec.tsx @@ -260,14 +260,30 @@ describe('DockSurface', () => { expect(disabled.getAttribute('data-dockkit-split-blocked')).toBe('budget') }) - it('hides capacity-blocked split controls when opted in and restores them when capacity returns', () => { + it('names the enabled split control through the shared tooltip, not a native title', () => { + vi.useFakeTimers() + try { + renderSurface(seededController(), spyIntents()) + const button = screen.getByRole('button', { name: TEST_LABELS.splitPane }) + expect(button.hasAttribute('title')).toBe(false) + fireEvent.mouseEnter(button) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByRole('tooltip').textContent).toBe(TEST_LABELS.splitPane) + fireEvent.mouseLeave(button) + expect(screen.queryByRole('tooltip')).toBeNull() + } finally { + vi.useRealTimers() + } + }) + + it('hides blocked split controls when opted in and restores them when capacity returns', () => { const controller = seededController() controller.splitPane() const state = controller.getSnapshot().state - layOut(dockPaneIds(state), 420) + layOut(dockPaneIds(state), 520) const intents = spyIntents() const props: DockSurfaceProps = { - state, canSplit: false, hideSplitAtCapacity: true, intents, labels: TEST_LABELS, renderTab: tab =>

{tab.title}

, + state, canSplit: false, hideSplitWhenBlocked: true, intents, labels: TEST_LABELS, renderTab: tab =>

{tab.title}

, } const view = render() expect(screen.queryByRole('button', { name: TEST_LABELS.splitPane })).toBeNull() @@ -285,15 +301,15 @@ describe('DockSurface', () => { // jsdom lays nothing out, so the room rule reads the rectangles this spec // hands it: two panes, one wide enough for two halves and one not. The // strip's fixed part is 104px in both (the chrome pane's controls), the chip - // minimum falls back to the stylesheet's 59px. - it.each([false, true])('keeps the width-blocked split control and its title with hideSplitAtCapacity=%s', (hideSplitAtCapacity) => { + // minimum falls back to the stylesheet's 100px. + it.each([false, true])('disables or hides the width-blocked split control with hideSplitWhenBlocked=%s', (hideSplitWhenBlocked) => { const controller = seededController() controller.setExpanded(true) controller.splitPane() const snapshot = controller.getSnapshot() const [wide, narrow] = dockPaneIds(snapshot.state) if (wide === undefined || narrow === undefined) throw new Error('expected two docked panes') - const widths: Record = { [wide]: 420, [narrow]: 208 } + const widths: Record = { [wide]: 520, [narrow]: 208 } vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { const paneWidth = widths[this.closest('[data-dockkit-pane]')?.dataset.dockkitPane ?? ''] ?? 0 if (this.hasAttribute('data-dockkit-pane')) return box(0, 0, paneWidth, 600) @@ -306,7 +322,7 @@ describe('DockSurface', () => {

{tab.contentId}

} @@ -315,10 +331,14 @@ describe('DockSurface', () => { const wideButton = document.querySelector(`[data-dockkit-split-button="${wide}"]`) const narrowButton = document.querySelector(`[data-dockkit-split-button="${narrow}"]`) expect(wideButton?.hasAttribute('disabled')).toBe(false) - expect(wideButton?.getAttribute('title')).toBe(TEST_LABELS.splitPane) - expect(narrowButton?.hasAttribute('disabled')).toBe(true) - expect(narrowButton?.getAttribute('title')).toBe(TEST_LABELS.splitPaneNarrow) - expect(narrowButton?.getAttribute('data-dockkit-split-blocked')).toBe('width') + expect(wideButton?.hasAttribute('title')).toBe(false) + if (hideSplitWhenBlocked) { + expect(narrowButton).toBeNull() + } else { + expect(narrowButton?.hasAttribute('disabled')).toBe(true) + expect(narrowButton?.getAttribute('title')).toBe(TEST_LABELS.splitPaneNarrow) + expect(narrowButton?.getAttribute('data-dockkit-split-blocked')).toBe('width') + } }) it('reports the room readings through onRoom, and re-reads them when the surface resizes', () => { @@ -342,7 +362,7 @@ describe('DockSurface', () => { controller.splitPane() const snapshot = controller.getSnapshot() const panes = dockPaneIds(snapshot.state) - let paneWidth = 420 + let paneWidth = 520 vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { if (this.hasAttribute('data-dockkit-pane')) return box(0, 0, paneWidth, 600) if (this.hasAttribute('data-dockkit-strip')) return box(0, 0, paneWidth - 2, 36) @@ -379,6 +399,194 @@ describe('DockSurface', () => { expect(observer.disconnect).toHaveBeenCalledTimes(1) }) + // The room reading must not flip with the hidden control's own footprint: + // hiding the width-blocked control widens the fill by the control plus the + // strip's gap, and a reading that counted the control would fit again, show + // it, and re-render forever (React's update-depth limit, which crashed the + // surface). The rule leaves the footprint out, so the fill the fake hands + // back here depends on whether the control is in the DOM — exactly the + // feedback the fix breaks. + it('reads the same room whether hideSplitWhenBlocked has hidden the split control or not', () => { + class FakeResizeObserver implements ResizeObserver { + static latest: FakeResizeObserver | undefined + readonly observe = vi.fn() + readonly unobserve = vi.fn() + readonly disconnect = vi.fn() + constructor(private readonly callback: ResizeObserverCallback) { + FakeResizeObserver.latest = this + } + + /** What the platform does when the observed element's size changes. */ + fire(): void { + this.callback([], this) + } + } + vi.stubGlobal('ResizeObserver', FakeResizeObserver) + const controller = seededController() + controller.setExpanded(true) + const snapshot = controller.getSnapshot() + // In the band where only the control's 28px footprint decides the fit: + // half 188px against 76px of other fixed controls plus the 100px chip. + let paneWidth = 380 + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + const pane = this.closest('[data-dockkit-pane]') + if (this.hasAttribute('data-dockkit-pane')) return box(0, 0, paneWidth, 600) + if (this.hasAttribute('data-dockkit-strip')) return box(0, 0, paneWidth - 2, 36) + if (this.hasAttribute('data-dockkit-strip-tabs')) return box(0, 0, 60, 24) + if (this.hasAttribute('data-dockkit-split-button')) return box(0, 0, 28, 28) + if (this.hasAttribute('data-dockkit-strip-fill')) { + const fixed = pane?.querySelector('[data-dockkit-split-button]') === null ? 76 : 104 + return box(0, 0, Math.max(0, paneWidth - 2 - 60 - fixed), 24) + } + return box(0, 0, 0, 0) + }) + render( +

{tab.contentId}

} + />, + ) + // Settled with the control shown: the discounted reading fits either way. + expect(document.querySelector('[data-dockkit-split-button]')).not.toBeNull() + + // Narrowed under the discounted minimum: hidden, and the reading without + // the control settles hidden. + const observer = FakeResizeObserver.latest + if (observer === undefined) throw new Error('expected the surface to observe its own size') + paneWidth = 300 + act(() => { observer.fire() }) + expect(document.querySelector('[data-dockkit-split-button]')).toBeNull() + }) + + it('names the chip box\'s hidden sides in data-dockkit-strip-scroll as it scrolls', () => { + let scrollLeft = 0 + const descriptors = ['scrollLeft', 'scrollWidth', 'clientWidth'].map(name => + [name, Object.getOwnPropertyDescriptor(Element.prototype, name)] as const) + const strip = (element: Element): boolean => element.hasAttribute('data-dockkit-strip-tabs') + Object.defineProperty(Element.prototype, 'scrollLeft', { configurable: true, get(this: Element) { return strip(this) ? scrollLeft : 0 } }) + Object.defineProperty(Element.prototype, 'scrollWidth', { configurable: true, get(this: Element) { return strip(this) ? 400 : 0 } }) + Object.defineProperty(Element.prototype, 'clientWidth', { configurable: true, get(this: Element) { return strip(this) ? 200 : 0 } }) + try { + const controller = seededController() + renderSurface(controller, spyIntents()) + const box = document.querySelector('[data-dockkit-strip-tabs]') + if (box === null) throw new Error('expected the chip box') + expect(box.getAttribute('data-dockkit-strip-scroll')).toBe('end') + scrollLeft = 100 + fireEvent.scroll(box) + expect(box.getAttribute('data-dockkit-strip-scroll')).toBe('start end') + scrollLeft = 200 + fireEvent.scroll(box) + expect(box.getAttribute('data-dockkit-strip-scroll')).toBe('start') + } finally { + for (const [name, descriptor] of descriptors) { + if (descriptor === undefined) Reflect.deleteProperty(Element.prototype, name) + else Object.defineProperty(Element.prototype, name, descriptor) + } + } + }) + + it('scrolls the chip box to the active chip when it lies past either edge, clearing the fade band', () => { + let scrollLeft = 0 + const descriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollLeft') + Object.defineProperty(Element.prototype, 'scrollLeft', { + configurable: true, + get() { return scrollLeft }, + set(value: number) { scrollLeft = value }, + }) + // The box spans 0..200; the seeded chip sits at 40..130 and the opened one where `openedAt` says. + let openedTab: TabId | undefined + let openedAt = 250 + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + if (this.hasAttribute('data-dockkit-strip-tabs')) return box(0, 0, 200, 28) + if (this.getAttribute('data-dockkit-tab') === openedTab) return box(openedAt, 0, 90, 28) + if (this.hasAttribute('data-dockkit-tab')) return box(40, 0, 90, 28) + return box(0, 0, 0, 0) + }) + const surface = (controller: DockController) => ( +

{tab.contentId}

} + /> + ) + try { + const controller = seededController() + const seededTab = getPane(controller.getSnapshot().state, controller.getSnapshot().state.activePaneId).activeTabId + if (seededTab === undefined) throw new Error('expected the seeded tab') + const { rerender } = render(surface(controller)) + // The seeded chip is in view: nothing moves. + expect(scrollLeft).toBe(0) + openedTab = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'file' }) + rerender(surface(controller)) + // Past the right edge by 140, plus the 24px fade. + expect(scrollLeft).toBe(164) + // Selected again once it lies 30 past the left edge: back by 30 plus the fade. + openedAt = -30 + controller.focusTab(seededTab) + rerender(surface(controller)) + controller.focusTab(openedTab) + rerender(surface(controller)) + expect(scrollLeft).toBe(164 - 30 - 24) + } finally { + if (descriptor === undefined) Reflect.deleteProperty(Element.prototype, 'scrollLeft') + else Object.defineProperty(Element.prototype, 'scrollLeft', descriptor) + } + }) + + it('marks a chip title clipped while its text is wider than its box, re-reading on resize', () => { + class FakeResizeObserver implements ResizeObserver { + static readonly all: FakeResizeObserver[] = [] + readonly observe = vi.fn() + readonly unobserve = vi.fn() + readonly disconnect = vi.fn() + constructor(private readonly callback: ResizeObserverCallback) { + FakeResizeObserver.all.push(this) + } + + fire(): void { + this.callback([], this) + } + } + vi.stubGlobal('ResizeObserver', FakeResizeObserver) + let clientWidth = 200 + const descriptors = ['scrollWidth', 'clientWidth'].map(name => + [name, Object.getOwnPropertyDescriptor(Element.prototype, name)] as const) + const title = (element: Element): boolean => element.hasAttribute('data-dockkit-tab-title') + Object.defineProperty(Element.prototype, 'scrollWidth', { configurable: true, get(this: Element) { return title(this) ? 120 : 0 } }) + Object.defineProperty(Element.prototype, 'clientWidth', { configurable: true, get(this: Element) { return title(this) ? clientWidth : 0 } }) + try { + const controller = seededController() + const { unmount } = renderSurface(controller, spyIntents()) + const span = document.querySelector('[data-dockkit-tab-title]') + if (span === null) throw new Error('expected a chip title') + const observer = FakeResizeObserver.all.find(candidate => candidate.observe.mock.calls.some(([target]) => target === span)) + if (observer === undefined) throw new Error('expected the title to observe its own size') + expect(span.hasAttribute('data-dockkit-tab-clipped')).toBe(false) + // The chip narrowed under the text. + clientWidth = 80 + act(() => { observer.fire() }) + expect(span.hasAttribute('data-dockkit-tab-clipped')).toBe(true) + clientWidth = 200 + act(() => { observer.fire() }) + expect(span.hasAttribute('data-dockkit-tab-clipped')).toBe(false) + unmount() + expect(observer.disconnect).toHaveBeenCalledTimes(1) + } finally { + for (const [name, descriptor] of descriptors) { + if (descriptor === undefined) Reflect.deleteProperty(Element.prototype, name) + else Object.defineProperty(Element.prototype, name, descriptor) + } + vi.unstubAllGlobals() + } + }) + it('asks for the seeded tab from the strip\'s add control, naming the pane and nothing else', () => { const controller = seededController() const intents = spyIntents() @@ -414,6 +622,59 @@ describe('DockSurface', () => { expect(strip?.querySelector('[data-dockkit-strip-tabs]')).not.toBeNull() }) + it('withholds a tab\'s close control and menu close item where the embedder\'s canCloseTab denies, tab by tab', () => { + const controller = seededController() + controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' }) + const snapshot = controller.getSnapshot() + const seedTabId = getPane(snapshot.state, snapshot.state.activePaneId).tabs[0] + if (seedTabId === undefined) throw new Error('expected seeded tab') + render( + tabId !== seedTabId} + intents={controller} + labels={TEST_LABELS} + renderTab={tab =>

{tab.contentId}

} + renderTabMenuItems={(_, dismiss) => } + />, + ) + const [seedChip, fileChip] = screen.getAllByRole('tab') + if (seedChip === undefined || fileChip === undefined) throw new Error('expected two chips') + expect(seedChip.querySelector('[data-dockkit-tab-close]')).toBeNull() + expect(fileChip.querySelector('[data-dockkit-tab-close]')).not.toBeNull() + + // The menu still opens on the withheld chip: the embedder's items remain reachable. + fireEvent.contextMenu(seedChip) + expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['embedder item']) + fireEvent.pointerDown(document.body) + + fireEvent.contextMenu(fileChip) + expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([TEST_LABELS.closeTab, 'embedder item']) + }) + + it('draws a lone unclosable chip quiet, and shows no menu on it when the embedder renders no item', () => { + const controller = seededController() + render( + false} + intents={controller} + labels={TEST_LABELS} + renderTab={tab =>

{tab.contentId}

} + renderTabMenuItems={() => undefined} + />, + ) + const [chip] = screen.getAllByRole('tab') + if (chip === undefined) throw new Error('expected the seeded chip') + // The lone unclosable chip draws quiet: no capsule, no hover fill. + expect(chip.getAttribute('data-dockkit-tab-quiet')).toBe('true') + // The menu would hold nothing, so it dismisses itself before painting. + fireEvent.contextMenu(chip) + expect(document.querySelector('[data-dockkit-tab-menu]')).toBeNull() + }) + it('lets the embedder render a chip\'s title, and shows the record\'s text when it does not', () => { const controller = seededController() controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' }) @@ -648,13 +909,13 @@ describe('tab drags', () => { }) it('shows the dock hint on the pane under the pointer and reports the zone on release', () => { - const { intents, second, fileTabId, chip } = twoPanes() - drag(chip(fileTabId), FILE_CHIP, [420 + 210, 300], false) + const { intents, second, fileTabId, chip } = twoPanes(520) + drag(chip(fileTabId), FILE_CHIP, [520 + 260, 300], false) const hint = document.querySelector(`[data-dockkit-pane="${second}"] [data-dockkit-dock-zone]`) expect(hint?.getAttribute('data-dockkit-dock-zone')).toBe('center') - fireEvent.pointerMove(window, { pointerId: 7, clientX: 420 + 410, clientY: 300 }) + fireEvent.pointerMove(window, { pointerId: 7, clientX: 520 + 510, clientY: 300 }) expect(document.querySelector('[data-dockkit-dock-zone]')?.getAttribute('data-dockkit-dock-zone')).toBe('right') - fireEvent.pointerUp(window, { pointerId: 7, clientX: 420 + 410, clientY: 300 }) + fireEvent.pointerUp(window, { pointerId: 7, clientX: 520 + 510, clientY: 300 }) expect(intents.dropTab).toHaveBeenCalledWith(fileTabId, second, 'right') expect(document.querySelector('[data-dockkit-dock-zone]')).toBeNull() }) @@ -672,6 +933,8 @@ describe('tab drags', () => { const { intents, second, fileTabId, chip } = twoPanes(208) drag(chip(fileTabId), FILE_CHIP, [208 + 200, 300], false) expect(document.querySelector('[data-dockkit-dock-zone]')).toBeNull() + fireEvent.pointerMove(window, { pointerId: 7, clientX: 208 + 104, clientY: 500 }) + expect(document.querySelector('[data-dockkit-dock-zone]')?.getAttribute('data-dockkit-dock-zone')).toBe('bottom') fireEvent.pointerMove(window, { pointerId: 7, clientX: 208 + 104, clientY: 100 }) expect(document.querySelector('[data-dockkit-dock-zone]')?.getAttribute('data-dockkit-dock-zone')).toBe('top') fireEvent.pointerUp(window, { pointerId: 7, clientX: 208 + 104, clientY: 100 }) @@ -764,10 +1027,10 @@ describe('tab drags', () => { describe('horizontal workbench drops', () => { it.each([ - { x: 450, y: 300, zone: 'left' }, - { x: 810, y: 590, zone: 'right' }, + { x: 550, y: 300, zone: 'left' }, + { x: 1010, y: 590, zone: 'right' }, ] as const)('offers both halves and targets $zone at ($x, $y)', ({ x, y, zone }) => { - const { intents, second, fileTabId, chip } = twoPanes(420, true, { dropZones: 'horizontal' }) + const { intents, second, fileTabId, chip } = twoPanes(520, true, { dropZones: 'horizontal' }) drag(chip(fileTabId), FILE_CHIP, [x, y], false) const hints = document.querySelectorAll('[data-dockkit-dock-zone]') expect([...hints].map(hint => hint.getAttribute('data-dockkit-dock-zone'))).toEqual(['left', 'right']) diff --git a/packages/client/ui-dockkit/tests/fixtures.client.ts b/packages/client/ui-dockkit/tests/fixtures.client.ts index a4c87a52e8..04a71c35bc 100644 --- a/packages/client/ui-dockkit/tests/fixtures.client.ts +++ b/packages/client/ui-dockkit/tests/fixtures.client.ts @@ -44,6 +44,7 @@ export const TEST_LABELS: DockLabels = { addTab: 'new tab', dockFloat: 'dock', closeFloat: 'close panel', + dropZone: { center: 'move here', left: 'split left', right: 'split right', top: 'split top', bottom: 'split bottom' }, } /** Brand a literal a spec spells out: an id the kit would have minted. */ diff --git a/packages/client/ui-dockkit/tests/geometry.client.spec.ts b/packages/client/ui-dockkit/tests/geometry.client.spec.ts index 69f1e821b8..93b6a6e1e2 100644 --- a/packages/client/ui-dockkit/tests/geometry.client.spec.ts +++ b/packages/client/ui-dockkit/tests/geometry.client.spec.ts @@ -118,26 +118,26 @@ describe('halvesFit — the room rule', () => { }) it('needs each half to hold the strip\'s fixed controls plus one minimum chip', () => { - // 420px: halves of 206px inside the borders, against 104 + 59. - expect(halvesFit(measure(420, 600, 104)).row).toBe(true) - // 208px: halves of 100px, short of 163. + // 520px: halves of 258px inside the borders, against 104 + 100. + expect(halvesFit(measure(520, 600, 104)).row).toBe(true) + // 208px: halves of 102px, short of 204. expect(halvesFit(measure(208, 600, 104)).row).toBe(false) - // The boundary is inclusive: 2 * (163 + 2 borders) + 4 divider = 334. - expect(halvesFit(measure(334, 600, 104)).row).toBe(true) - expect(halvesFit(measure(333, 600, 104)).row).toBe(false) - // A strip with fewer controls needs less. - expect(halvesFit(measure(208, 600, 44)).row).toBe(false) - expect(halvesFit(measure(214, 600, 44)).row).toBe(true) + // The boundary is inclusive: 2 * (204 + 2 borders) = 412; the divider takes no room. + expect(halvesFit(measure(412, 600, 104)).row).toBe(true) + expect(halvesFit(measure(411, 600, 104)).row).toBe(false) + // A strip with fewer controls needs less: 2 * (144 + 2) = 292. + expect(halvesFit(measure(291, 600, 44)).row).toBe(false) + expect(halvesFit(measure(292, 600, 44)).row).toBe(true) }) it('needs each half to hold the strip plus a minimum body for a column split', () => { - // Halves of (h - 4) / 2 - 2 against 36 + 48 = 84. - expect(halvesFit(measure(420, 176, 104)).column).toBe(true) - expect(halvesFit(measure(420, 175, 104)).column).toBe(false) + // Halves of h / 2 - 2 against 36 + 48 = 84. + expect(halvesFit(measure(420, 172, 104)).column).toBe(true) + expect(halvesFit(measure(420, 171, 104)).column).toBe(false) }) it('takes the minimums it is given, and the stylesheet\'s by default', () => { - expect(SPLIT_MINIMUMS).toEqual({ divider: 4, chip: 59, body: 48 }) + expect(SPLIT_MINIMUMS).toEqual({ divider: 0, chip: 100, body: 48 }) expect(halvesFit(measure(208, 600, 104), { divider: 0, chip: 0, body: 0 }).row).toBe(false) expect(halvesFit(measure(220, 600, 104), { divider: 0, chip: 0, body: 0 }).row).toBe(true) }) diff --git a/packages/client/ui-dockkit/tests/measure.client.spec.ts b/packages/client/ui-dockkit/tests/measure.client.spec.ts index 52f30d3237..85342bb255 100644 --- a/packages/client/ui-dockkit/tests/measure.client.spec.ts +++ b/packages/client/ui-dockkit/tests/measure.client.spec.ts @@ -57,21 +57,21 @@ describe('measurePaneFits', () => { const root = surface() const bare = document.createElement('section') bare.dataset.dockkitPane = 'bare' - root.append(pane('wide', 420), bare) + root.append(pane('wide', 520), bare) expect(paneElements(root).map(([id]) => id)).toEqual(['wide', 'bare']) const fits = measurePaneFits(root) expect(fits.get(asPane('wide'))).toEqual({ row: true, column: true }) expect(fits.get(asPane('bare'))).toEqual({ row: true, column: true }) }) - // 308px: halves of 150px inside the borders, against 104px of controls plus one chip. + // 308px: halves of 152px inside the borders, against 104px of controls plus one chip. it('reads the chip minimum from a rendered chip\'s computed style, padding included for a content box', () => { const root = surface() const narrow = pane('p', 308) root.append(narrow) - // No chip rendered: the stylesheet's 59px, so 163 > 150. + // No chip rendered: the stylesheet's 100px, so 204 > 152. expect(measurePaneFits(root).get(asPane('p'))?.row).toBe(false) - // 44px of content plus 4px + 4px of padding is 52: 156 > 150. + // 44px of content plus 4px + 4px of padding is 52: 156 > 152. const rendered = chip(narrow, { minWidth: '44px', paddingLeft: '4px', paddingRight: '4px', boxSizing: 'content-box' }) expect(measurePaneFits(root).get(asPane('p'))?.row).toBe(false) // The same declaration as a border box is the whole footprint: 148 fits. @@ -84,10 +84,10 @@ describe('measurePaneFits', () => { expect(measurePaneFits(root).get(asPane('p'))?.row).toBe(false) }) - // 336px: halves of 164px against 163 with the stylesheet's 4px divider, 162 with an 8px one. + // 416px: halves of 204px against 204 with the stylesheet's zero divider, 202 with an 8px one. it('reads the divider\'s thickness from a rendered divider, and the stylesheet\'s before one exists', () => { const root = surface() - root.append(pane('p', 336)) + root.append(pane('p', 416)) expect(measurePaneFits(root).get(asPane('p'))?.row).toBe(true) const divider = document.createElement('div') divider.dataset.dockkitDivider = 's:0' diff --git a/packages/client/ui-dockkit/tests/planner.client.spec.ts b/packages/client/ui-dockkit/tests/planner.client.spec.ts index 16d7fcb339..fa483964dc 100644 --- a/packages/client/ui-dockkit/tests/planner.client.spec.ts +++ b/packages/client/ui-dockkit/tests/planner.client.spec.ts @@ -133,7 +133,7 @@ describe('planPlaceTab', () => { }) describe('planDropTab on the tab\'s own pane', () => { - it('plans nothing for a pane\'s only tab released on any of its own edges', () => { + it('plans nothing for a pane\'s only tab released on any of its own edges without a factory', () => { const { state, minter } = seededState() const mint = minter.next const tabId = getPane(state, state.rootId).tabs[0] @@ -143,12 +143,33 @@ describe('planDropTab on the tab\'s own pane', () => { } }) - it('splits the pane when it keeps another tab', () => { + it('splits on a sole tab\'s own edge when a factory backfills the pane it vacates', () => { + const { state, minter } = seededState() + const mint = minter.next + const paneId = getPane(state, state.rootId).id + const tabId = getPane(state, paneId).tabs[0] + if (tabId === undefined) throw new Error('fixture: seeded tab missing') + const ops = planDropTab(state, mint, tabId, paneId, 'right', seedTab) + expect(ops.map(op => op.type)).toEqual(['split', 'openTab', 'moveTab']) + const split = applyAll(state, ops) + const [home, destination] = dockPaneIds(split) + if (home === undefined || destination === undefined) throw new Error('expected two panes') + expect(getPane(split, home).tabs).toHaveLength(1) + expect(getPane(split, home).tabs[0]).not.toBe(tabId) + expect(getPane(split, destination).tabs).toEqual([tabId]) + // The backfill seats first, so the moved tab ends focused. + expect(split.activePaneId).toBe(destination) + }) + + it('splits without a backfill when the pane keeps another tab, factory or not', () => { const { state, minter } = seededState() const mint = minter.next const opened = planOpenContent(state, mint, { contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' }) const two = applyAll(state, opened.ops) - const ops = planDropTab(two, mint, opened.tabId, getPane(two, two.rootId).id, 'right') + const target = getPane(two, two.rootId).id + expect(planDropTab(two, mint, opened.tabId, target, 'right') + .map(op => op.type)).toEqual(['split', 'moveTab']) + const ops = planDropTab(two, mint, opened.tabId, target, 'right', seedTab) expect(ops.map(op => op.type)).toEqual(['split', 'moveTab']) const split = applyAll(two, ops) expect(dockPaneIds(split)).toHaveLength(2) diff --git a/packages/client/ui-dockkit/tsconfig.json b/packages/client/ui-dockkit/tsconfig.json index 2139bcf7ba..82162fc41c 100644 --- a/packages/client/ui-dockkit/tsconfig.json +++ b/packages/client/ui-dockkit/tsconfig.json @@ -10,6 +10,9 @@ "references": [ { "path": "../../util/brand" + }, + { + "path": "../ui-primitives" } ] } diff --git a/packages/client/ui-goal/package.json b/packages/client/ui-goal/package.json index 5d516249f9..49f1105462 100644 --- a/packages/client/ui-goal/package.json +++ b/packages/client/ui-goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-goal", "description": "Session goal surface: GoalBar docked above the composer, read from the goal session projection", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-input-trigger/package.json b/packages/client/ui-input-trigger/package.json index 65e30abb16..d6f3bd7fb6 100644 --- a/packages/client/ui-input-trigger/package.json +++ b/packages/client/ui-input-trigger/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-input-trigger", "description": "Input trigger pipeline: '/' and '@' detection, candidate menu, pick routing to registered sources", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-jobs/package.json b/packages/client/ui-jobs/package.json index cd0ea7ebe9..196567bb7a 100644 --- a/packages/client/ui-jobs/package.json +++ b/packages/client/ui-jobs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-jobs", "description": "Session-header background-job list: live registry state mirrored from session/jobs frames", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", diff --git a/packages/client/ui-layout/package.json b/packages/client/ui-layout/package.json index a2c025f39d..1beae6f3b0 100644 --- a/packages/client/ui-layout/package.json +++ b/packages/client/ui-layout/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-layout", "description": "Shell plugin: three-column AppFrame with drag handles, ctx.layout viewing-state service (navigation + panels)", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-message-feedback/README.i18n.yaml b/packages/client/ui-message-feedback/README.i18n.yaml index bd847fc520..9d090b4d79 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: 5e33637f80be96dd2ae4fb89c111cef98697469e +README.md: 8ffcf87a59ec95fe783a0379423b93d4bf965a3e +README.zh.md: 36fc54c945e8c25af03e5f307478b0c5fce811aa 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 5e33637f80..36fc54c945 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 划分的控制器 map,两者都由同一个 effect disposer 释放。生命周期 spec 证明,所属 fiber 释放时会撤销该注册并丢弃所有控制器,因此不存在需要在运行时检查的第二权威来源。 +**运行时不变式:** 不发布伴生入口。插件持有两个 slot 注册、一个命令装饰,以及一个按 Session 划分的控制器对 map;它们都由插件 fiber 的同一个 effect disposer 释放。生命周期规格测试证明,所属 fiber 释放时会撤销所有注册并丢弃所有控制器,因此不存在需要在运行时检查的第二权威来源。 diff --git a/packages/client/ui-message-feedback/package.json b/packages/client/ui-message-feedback/package.json index b73978ed20..343f289faa 100644 --- a/packages/client/ui-message-feedback/package.json +++ b/packages/client/ui-message-feedback/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-message-feedback", - "description": "Per-message feedback controls contributed to the assistant-message action strip, backed by the messageFeedback Host Remote", - "version": "0.1.5-alpha.1", + "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.2", "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 => ( + + ))} +
+