diff --git a/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.i18n.yaml index b34903faef..1f7596b82f 100644 --- a/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.i18n.yaml @@ -9,8 +9,8 @@ en: 84858bc0e1e268ab zh: 39ebb2979968d6cc /agent-note-chat-group-tree-positions-and-exchanges/decision: - en: ca3153ae96994baa - zh: de6c7d925559efbb + en: 043e48fc04003f7d + zh: 15842c3db3fc83f4 /agent-note-chat-group-tree-positions-and-exchanges/why-mark-the-opening-rather-than-the-notice: en: db8d77cd773ad646 zh: ab2f03b8fc58e952 diff --git a/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.md b/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.md index 0fffb84e71..4eff27ab5b 100644 --- a/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.md +++ b/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.md @@ -14,7 +14,7 @@ A notice is neither body text nor a user message: folding and bottom advancement - **Group-tree positions.** `ChatConversationViewNode.groupTree` declares a Node's position in the group tree, typed `ChatGroupTree = 'exchange' | 'input' | 'turn' | 'process'` and ordered outermost first; a Node at a position also has the effect of every inner position. `exchange` opens an exchange. `input` makes the Node an input of its Turn: before the Turn's process it is the opening input, after it an interleaved input that keeps the whole Turn open. `turn` keeps the Node outside the Turn's process disclosure, `process` keeps it inside the disclosure but outside step runs, and a Node without a position joins the surrounding step run. `isAtOrOutside(node, position)` is the single reader of that order: process grouping reads `process`, whole-Turn folding reads `turn`, Turn-input detection reads `input`, and exchanges read `exchange`. - **Exchanges.** A Turn holding an `exchange` Node opens an exchange, which lasts until the next opening Turn. The business message Definition marks only human input claimed in Step 1 (`user` or `steering`) as `exchange`; later human input and trigger notices stand at `input` and never open an exchange. -- **Built-in positions.** `turn-error`, `turn-max-tokens`, `turn-tail`, and the `turn-process` control stand at `turn`; `model-retry` stands at `process`. Two placement rules stay with ui-chat's own `assistant-step` Node: its reasoning row joins the step run while its reply row stands outside it, and the Turn's process specification keeps the finalized answer's reply outside the disclosure because which step answers changes as later steps arrive. +- **Built-in positions.** `turn-error`, `turn-max-tokens`, `turn-tail`, and the `turn-process` control stand at `turn`; `model-retry` stands at `process`. Two placement rules stay with ui-chat's own `assistant-step` Node: its reasoning row joins the step run while its reply row stands outside it, and with the default Replies After Collapse choice every reply row also stays outside the whole-Turn disclosure ([keep every Assistant reply](../feature/2026-10-15-chat-keep-assistant-replies.md)). - **Mechanism.** `GroupTreeSegments(position)` splits the loaded Turns at the Turns holding a Node at that position. The latest segment starts at the last opening Turn, or at the first loaded Turn when the window holds none. It counts per Turn incrementally without scanning loaded Nodes, and a start move returns only the Turns that entered or left the latest segment. The Chat Builder's `bottomSource` is "the Node is visible and belongs to the latest exchange". As a result, a notice-woken Turn no longer folds earlier Turns, so no room is reserved; the human's next ordinary message folds the whole exchange through the own-input path. diff --git a/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.zh.md b/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.zh.md index 395faed58b..7cf1be445c 100644 --- a/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.zh.md @@ -14,7 +14,7 @@ Status: implemented - **分组树位置。** `ChatConversationViewNode.groupTree` 声明节点在分组树中的位置,类型为 `ChatGroupTree = 'exchange' | 'input' | 'turn' | 'process'`,由外到内有序;站在某个位置的节点同时具有所有内层位置的效果。`exchange` 开启一个交互段。`input` 表示节点是所在 Turn 的输入:出现在该轮过程之前即为开场输入,之后出现即为中途输入,使整个 Turn 保持展开。`turn` 让节点留在 Turn 的整轮折叠之外,`process` 让节点留在整轮折叠之内、步骤组之外,没有位置的节点并入相邻的步骤组。`isAtOrOutside(node, position)` 是读取这一顺序的唯一入口:过程分组读 `process`,整轮折叠读 `turn`,轮次输入判断读 `input`,交互段读 `exchange`。 - **交互段。** 含 `exchange` 节点的 Turn 开启一个交互段,交互段持续到下一个开启 Turn。业务层的 message Definition 只给 Step 1 中被认领的真人输入(`user` 或 `steering`)标 `exchange`;之后的真人输入和触发通知位于 `input`,不开启交互段。 -- **内置位置。** `turn-error`、`turn-max-tokens`、`turn-tail` 与 `turn-process` 控件位于 `turn`;`model-retry` 位于 `process`。ui-chat 自己的 `assistant-step` 节点保留两条放置规则:推理行并入步骤组、回复行在步骤组之外;该轮的过程规格让最终答案的回复留在整轮折叠之外,因为哪一步是最终答案会随后续步骤到来而变化。 +- **内置位置。** `turn-error`、`turn-max-tokens`、`turn-tail` 与 `turn-process` 控件位于 `turn`;`model-retry` 位于 `process`。ui-chat 自己的 `assistant-step` 节点保留两条放置规则:推理行并入步骤组、回复行在步骤组之外;在默认的「收起后显示的回复」选项下,每个回复行也留在整轮折叠之外([整轮折叠保留全部 Assistant 回复](../feature/2026-10-15-chat-keep-assistant-replies.zh.md))。 - **机制。** `GroupTreeSegments(position)` 按含该位置节点的 Turn 切分已加载的 Turn,最新一段从最后一个开启 Turn 开始,窗口中没有开启 Turn 时从第一个已加载 Turn 开始。它按 Turn 增量计数,不扫描已加载节点;起点移动时只返回进入或离开最新一段的 Turn。Chat Builder 的 `bottomSource` 为"节点可见且属于最新交互段"。 结果是:通知唤醒的 Turn 不再折叠前面的 Turn,没有折叠就没有补白;真人下一次普通输入时,整个交互段经由自己输入的路径一起折叠。 diff --git a/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.i18n.yaml new file mode 100644 index 0000000000..954a507a27 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-10-18-session-list-summary-projections.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.md +/agent-note-serve-session-list-rows-from-a-summary-key-set: + en: 983b028b1d2aeb7c + zh: 2a2d3ced786ee686 +/agent-note-serve-session-list-rows-from-a-summary-key-set/problem: + en: 9f84c9bd85b6aace + zh: 8ebb10962c219624 +/agent-note-serve-session-list-rows-from-a-summary-key-set/decision: + en: 6e8f43d7a53c35fd + zh: a6e2bccc20474515 +/agent-note-serve-session-list-rows-from-a-summary-key-set/alternatives-considered: + en: ce8320f1032018ed + zh: 4c5fad5e605bf524 +/agent-note-serve-session-list-rows-from-a-summary-key-set/consequences: + en: d3465b78160d8c1b + zh: dcbad6768b74b2bf +/agent-note-serve-session-list-rows-from-a-summary-key-set/testing: + en: 6b34571bdadb0a9d + zh: 41ebfc1b5325b8d6 diff --git a/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.md b/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.md new file mode 100644 index 0000000000..89e7b2c37d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.md @@ -0,0 +1,52 @@ +# Agent Note: Serve Session-list rows from a summary key set + +Status: implemented + +English | [中文](2026-10-18-session-list-summary-projections.zh.md) + +## Problem + +A Session-list row carries every registered client view. Four of those views are never rendered by a row: the whole-log turn outline, the whole-log conversation figures, the current todo list, and the host-wide image limits. The outline grows with the Session's log, so a list payload scales with corpus size and log length even though no row shows it. Omitting values from the list needs a rule that keeps the wire and client types honest: a summary consumer must not obtain an omitted key, a value that is omitted must still reach an opened Session, and the owning packages must not drift from whatever declaration drives the omission. + +## Decision + +The Session summary is every registered client view except the keys whose `SessionProjectionMap` entry carries the summary-exclusion mark. + +- [`packages/session/session-projection/src/types.ts`](../../../../packages/session/session-projection/src/types.ts) declares the type-only mark `SessionProjectionSummaryExcluded` over a unique symbol that is never exported and never present at runtime. `SessionProjectionViewValue` strips it distributively, so a nullable entry keeps its `null`; `SessionProjectionSummaryExcludedKey` tests the non-nullish part; `SessionProjectionSummaryValues` is every view value minus those keys. +- Each owning package wraps only its own map entry: `turnOutline` (session-turn-outline), `sessionStats` (session-stats), `todos` (tool-todo), and `imageLimits` (session-controller's boot-constant attachment limits, which every Session shares). The todo entry is `SessionProjectionSummaryExcluded | null`, because an intersection with `null` would absorb the null member. +- Registration carries the same decision as a plain boolean: `wire.summary` says whether a Session summary includes the unit, and omitting it means `true`. A key whose map entry carries the mark declares `false` so the runtime key set and the client view type agree. The mark itself has no runtime representation — `wire.view` and `viewSchema` are typed with `SessionProjectionViewValue`, which strips it, so the erase in `register` only widens the state type. +- [`packages/session/session-projection/src/index.ts`](../../../../packages/session/session-projection/src/index.ts) exposes `summaryKeys(): ReadonlySet`, derived from the live registrations and rebuilt only when a new key registers or a key's last registrant disposes. `cachedSnapshot`, `viewCheckpoint`, and `snapshot` accept `ProjectionKeySelection` (`readonly Key[] | ReadonlySet`) and use a passed set as-is. +- [`packages/session/session-projection-cache/src/index.ts`](../../../../packages/session/session-projection-cache/src/index.ts) forwards that selection unchanged, and [`packages/api/session-controller/src/list.ts`](../../../../packages/api/session-controller/src/list.ts) passes `summaryKeys()` to the live and cold cached faces. `cachedPredecessorTitle` keeps its title-only read. +- Client types only: `SessionSummary.projectionValues` narrows to `Partial`, while `SessionProjectionSnapshot.values` keeps every view value. No wire field, function, or UI name changes, and an opened Session still receives every key through `session/follow`, the control baseline, and `session.projections`. + +Estimated effect: with logs whose outlines dominate the projection payload, the four keys are estimated at roughly two thirds of a Session-list response for a few hundred Sessions — the outline alone near 57%, the figures and todo list near 5% together, and the shared image limits near 5% — and at roughly 85% of the same response after transport compression, where the outline's unique text barely compresses while the remaining keys do. + +## Alternatives considered + +**A summary-specific read face per source** (`viewSummaryRecord` for checkpoint rows and `cachedSummarySnapshot` for live cells). Rejected: the summary key set would then be defined and carried by two read faces plus their call sites, and a caller could still pass explicit keys to the complete faces. The derived set plus the existing key-taking faces keeps one definition, one code path, and no per-read key copy. + +**A hardcoded excluded-key list in the session controller.** Rejected: the list would drift from the owning packages, and nothing would fail when a package renamed a key or added a large value. + +**Filtering keys after the complete block is built.** Rejected: the per-row state parse and view work would still happen, and the wire type would keep claiming the excluded keys are present. + +**Making every projection opt in to summaries.** Rejected: it inverts today's default, requires touching every owning package, and silently drops a new large key from summaries whenever a package forgets to opt in. + +**A lazy per-value read for large projections.** Rejected: rows do not need the values at all, and deferring one value per row adds a request shape and loading states the list never had. + +## Consequences + +Bought: a list response no longer scales with outline length or log count, a summary consumer cannot obtain an excluded key through the derived set (the complete faces still serve an explicitly named key), and reading an excluded key from a list row's type is a type error. `wire.summary` is a plain boolean, so keeping a marked key at `false` is the registering package's responsibility rather than a compile error. + +A list row carries the summary projection, never the full projection: it promises the summary key set and nothing more. A connected Session's client store may hold more values from its control/follow baseline, but those are outside the list contract and must not be read from a row; the opened Session's own baselines keep every value. + +No upgrade guide records this change. The externally perceptible part is the wire: `session/list` rows no longer carry the four keys, and a client type that reads `projectionValues.turnOutline` no longer compiles. A consumer migrating reads the opened Session instead — its `session/follow` opening snapshot, the `session/control` baseline, or a `session.projections` read — which keep every projected value; the package README and the subsystem page state the rule. + +Paid: `ProjectionKeySelection` is a new public signature type and `ReadonlySet` joins the catalog's foundation types; `summaryKeys()` has two invalidation points (a new key, the last disposer) that a future registration path must not miss; the summary key set, like the unit table, is process-wide, so a key excluded by one preset is excluded for every Session; and a nullable value cannot be marked directly — its `null` must stay outside the wrapper. + +## Testing + +- [`packages/api/session-controller/tests/summary-list.host.spec.ts`](../../../../packages/api/session-controller/tests/summary-list.host.spec.ts): a list row's keys equal the registry's complete snapshot minus the marked keys, a `session/follow` opening snapshot still carries them with equal values, and two consecutive pulls do not re-add them. +- [`packages/session/session-projection/tests/summary.spec.ts`](../../../../packages/session/session-projection/tests/summary.spec.ts): the derived set, its stable identity, its rebuild after registration and disposal, summary-shaped reads through the key-taking faces, an empty summary set, and the nullable mark. +- [`packages/session/session-projection-cache/tests/cache.spec.ts`](../../../../packages/session/session-projection-cache/tests/cache.spec.ts): a summary read from stored rows omits the marked key and takes its watermark from the served rows only. +- [`packages/api/session-controller/tests/summary-types.client.spec.ts`](../../../../packages/api/session-controller/tests/summary-types.client.spec.ts): `@ts-expect-error` reads of `turnOutline`, `sessionStats`, and `todos` on `SessionSummary.projectionValues`, with the full snapshot still exposing them and `todos` still nullable. +- [`packages/api/session-controller/tests/manager.client.spec.ts`](../../../../packages/api/session-controller/tests/manager.client.spec.ts): a reconnect refresh replaces the row block, so a stale excluded key does not survive. diff --git a/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.zh.md b/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.zh.md new file mode 100644 index 0000000000..75c753c345 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.zh.md @@ -0,0 +1,52 @@ +# Agent Note: Serve Session-list rows from a summary key set + +Status: implemented + +[English](2026-10-18-session-list-summary-projections.md) | 中文 + +## Problem + +Session 列表行携带每个已注册的客户端视图。其中四个视图从不被行渲染:整份日志的 turn outline、整份日志的会话统计、当前 todo 清单,以及宿主级的图片上限。outline 随 Session 日志增长,因此列表响应会随语料规模与日志长度增长,而没有一行展示它。从列表省略取值需要一条规则同时守住 wire 与客户端类型:摘要消费方不能取得被省略的键;被省略的值仍要送达打开的 Session;owning package 不得与驱动省略的声明漂移。 + +## Decision + +Session 摘要就是每个已注册客户端视图,减去 `SessionProjectionMap` 条目带摘要排除标记的键。 + +- [`packages/session/session-projection/src/types.ts`](../../../../packages/session/session-projection/src/types.ts) 声明类型层标记 `SessionProjectionSummaryExcluded`,它基于一个从不导出、运行时从不存在的 unique symbol。`SessionProjectionViewValue` 以分发方式去掉标记,可空条目因此保留 `null`;`SessionProjectionSummaryExcludedKey` 取非空部分判断;`SessionProjectionSummaryValues` 是全部视图值减去这些键。 +- 每个 owning package 只包装自己的 map 条目:`turnOutline`(session-turn-outline)、`sessionStats`(session-stats)、`todos`(tool-todo),以及 `imageLimits`(session-controller 的启动常量附件上限,每个 Session 共享)。todo 条目写作 `SessionProjectionSummaryExcluded | null`,因为与 `null` 求交会吞掉 null 成员。 +- 注册用普通布尔值承载同一决定:`wire.summary` 表示 Session 摘要是否包含该单元,省略即 `true`。map 条目带标记的 key 声明 `false`,让运行时键集合与客户端视图类型一致。标记本身没有运行时表示 —— `wire.view` 与 `viewSchema` 的签名已用 `SessionProjectionViewValue` 去标记,因此 `register` 里的擦除只是放宽状态类型。 +- [`packages/session/session-projection/src/index.ts`](../../../../packages/session/session-projection/src/index.ts) 提供 `summaryKeys(): ReadonlySet`,从实时注册派生,只有新键注册或某键最后一个注册者注销时才重建。`cachedSnapshot`、`viewCheckpoint`、`snapshot` 接受 `ProjectionKeySelection`(`readonly Key[] | ReadonlySet`),传入 Set 时直接使用。 +- [`packages/session/session-projection-cache/src/index.ts`](../../../../packages/session/session-projection-cache/src/index.ts) 原样转发该选择,[`packages/api/session-controller/src/list.ts`](../../../../packages/api/session-controller/src/list.ts) 把 `summaryKeys()` 传给 live 与 cold 两个缓存读取面。`cachedPredecessorTitle` 保持只读 title。 +- 客户端只有类型变化:`SessionSummary.projectionValues` 收窄为 `Partial`,而 `SessionProjectionSnapshot.values` 保留全部视图值。wire 字段、函数名与 UI 名称均不变;打开的 Session 仍通过 `session/follow`、控制基线与 `session.projections` 收到每个键。 + +估算效果:当 outline 在投影负载中占主导时,四个键估计占几百个 Session 时列表响应的约三分之二 —— outline 单项约 57%,统计与 todo 合计约 5%,共享的图片上限约 5% —— 占传输压缩后同一响应的约 85%,因为 outline 的唯一文本几乎无法压缩,而其余键可以。 + +## Alternatives considered + +**为每个来源各做一个摘要专用读取面**(checkpoint 行的 `viewSummaryRecord` 与实时 cell 的 `cachedSummarySnapshot`)。否决:摘要键集合会由两个读取面及其调用点各定义一次,而调用方仍可向完整读取面传显式键。派生集合加既有带键读取面只保留一处定义、一条代码路径,且每次读取无需复制键。 + +**在 session controller 里硬编码被排除键清单。** 否决:清单会与 owning package 漂移,且包改名或新增一个大值时没有任何检查会失败。 + +**先构建完整块再按键过滤。** 否决:逐行的 state 解析与 view 工作仍会发生,而且 wire 类型会继续声称被排除键存在。 + +**让每个投影主动选择加入摘要。** 否决:这反转了当前默认,需要改动每个 owning package,并且某包忘记加入时新的大键会被静默地从摘要中漏掉。 + +**为大投影做按值惰性读取。** 否决:行根本不需要这些值,逐行延迟一个值会引入列表从未有过的请求形状与加载态。 + +## Consequences + +换来的:列表响应不再随 outline 长度或日志数量增长;摘要消费方无法通过派生集合取得被排除的键(完整读取面仍按显式键提供);从列表行的类型读取被排除键是类型错误。`wire.summary` 是普通布尔值,因此让带标记的键保持 `false` 是注册方自己的责任,而不是编译错误。 + +列表行携带的是摘要投影,绝不是全量投影:它只承诺摘要键集合,不承诺更多。已连接 Session 的客户端 store 可能因 control/follow 基线持有更多值,但那不属于列表契约,不得从行上读取;打开的 Session 自身的基线保留全部取值。 + +本次没有升级指南记录该变更。外部可感知的部分在 wire:`session/list` 行不再携带四个键,读取 `projectionValues.turnOutline` 的客户端类型不再编译。迁移方式是改读打开的 Session —— 其 `session/follow` 初始快照、`session/control` 基线或 `session.projections` 读取 —— 它们保留全部投影值;包 README 与子系统页写明了该规则。 + +付出的:`ProjectionKeySelection` 是新的公开签名类型,`ReadonlySet` 也需加入 catalog 的 foundation 类型;`summaryKeys()` 有两个失效点(新键、最后一个 disposer),未来的注册路径不得漏掉;摘要键集合与单元表一样是进程级的,某个 preset 排除的键对所有 Session 都排除;可空值不能直接标记 —— 它的 `null` 必须留在包装之外。 + +## Testing + +- [`packages/api/session-controller/tests/summary-list.host.spec.ts`](../../../../packages/api/session-controller/tests/summary-list.host.spec.ts):列表行的键等于注册表完整快照减去被标记的键;`session/follow` 初始快照仍携带它们且值相等;连续两次拉取不会重新加入它们。 +- [`packages/session/session-projection/tests/summary.spec.ts`](../../../../packages/session/session-projection/tests/summary.spec.ts):派生集合、其稳定实例、注册与注销后的重建、经带键读取面的摘要式读取、空摘要集合,以及可空标记。 +- [`packages/session/session-projection-cache/tests/cache.spec.ts`](../../../../packages/session/session-projection-cache/tests/cache.spec.ts):从持久行读取摘要时省略被标记键,且水位只取自被服务的行。 +- [`packages/api/session-controller/tests/summary-types.client.spec.ts`](../../../../packages/api/session-controller/tests/summary-types.client.spec.ts):在 `SessionSummary.projectionValues` 上读取 `turnOutline`、`sessionStats`、`todos` 触发 `@ts-expect-error`,而完整快照仍暴露它们且 `todos` 仍可空。 +- [`packages/api/session-controller/tests/manager.client.spec.ts`](../../../../packages/api/session-controller/tests/manager.client.spec.ts):断线重连刷新会替换行块,残留的被排除键不会存活。 diff --git a/.agents/notes/implemented/feature/2026-10-15-chat-keep-assistant-replies.i18n.yaml b/.agents/notes/implemented/feature/2026-10-15-chat-keep-assistant-replies.i18n.yaml new file mode 100644 index 0000000000..53e6201226 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-15-chat-keep-assistant-replies.i18n.yaml @@ -0,0 +1,31 @@ +# Bilingual-pair consistency record for 2026-10-15-chat-keep-assistant-replies.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-10-15-chat-keep-assistant-replies.md +/agent-note-keep-every-assistant-reply-when-a-turn-folds: + en: 813e0e2590457f2d + zh: 12b01305ae439712 +/agent-note-keep-every-assistant-reply-when-a-turn-folds/problem: + en: 967c8df85ef30286 + zh: 7cd5f219fe42f567 +/agent-note-keep-every-assistant-reply-when-a-turn-folds/decision: + en: 7f59f284df26b5fe + zh: b6ae20ffdb506ac3 +/agent-note-keep-every-assistant-reply-when-a-turn-folds/decision/what-stays-visible: + en: 41d8322fb0a8d010 + zh: a413bfdffb34833f +/agent-note-keep-every-assistant-reply-when-a-turn-folds/decision/what-does-not-change: + en: 352c5bdf25c09c62 + zh: 24878584535b4d57 +/agent-note-keep-every-assistant-reply-when-a-turn-folds/decision/implementation: + en: e40156780daec8fe + zh: 4447a61c2b1574c8 +/agent-note-keep-every-assistant-reply-when-a-turn-folds/alternatives-considered: + en: 28694c0a82df72ac + zh: 8f6f81c833cf8f2c +/agent-note-keep-every-assistant-reply-when-a-turn-folds/consequences: + en: d648f688c7e6c87d + zh: d84f1a43de8992b2 +/agent-note-keep-every-assistant-reply-when-a-turn-folds/testing: + en: 70133688614f2a5c + zh: 931a2710211ced3f diff --git a/.agents/notes/implemented/feature/2026-10-15-chat-keep-assistant-replies.md b/.agents/notes/implemented/feature/2026-10-15-chat-keep-assistant-replies.md new file mode 100644 index 0000000000..95db1a9fc0 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-15-chat-keep-assistant-replies.md @@ -0,0 +1,111 @@ +# Agent Note: Keep Every Assistant Reply When a Turn Folds + +Status: implemented + +English | [中文](2026-10-15-chat-keep-assistant-replies.zh.md) + +## Problem + +In Compact, Standard, and Detailed, a normally completed Turn folds as a whole. Under the previous rule the reader saw only the input, the whole-Turn control, the **final answer**, and the footer. The final answer is chosen by position: the last Assistant message in the latest Step that has visible reply content and no tool-call block (`latestAnswer` in [turn-process.ts](../../../../packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts)). Every intermediate reply before it was hidden with the process. + +Position is not content. A Turn often continues after the answer: a settled-subagent notice wakes another Step, the Agent updates its records, or it calls a tool after answering. The last message then becomes a short addendum, or there is no text-only ending at all, and the real answer folds into the process (#6132, #5429). For example, after a long research summary, a subagent completion notice woke one more Step whose message added a single remark; that remark was all the reader saw. + +We made a rough estimate of how intermediate replies are distributed. The proportions below are order-of-magnitude judgments, not statistical results: + +- In main Sessions, intermediate replies before the last reply make up about 10% of the Assistant text; subagent Sessions write more fragmented replies, about 20%. +- By count, more than half are one- or two-line "next I will…" messages, so showing all of them adds redundancy. +- A few longer intermediate replies carry actual conclusions or deliverables. Under the previous rule they folded with the process, which directly affected whether the reader understood the result. +- Some subagent tasks end with a structured-result tool call and no text-only reply, so under the previous rule the whole Turn showed no text at all. + +## Decision + +By default, a folded Turn hides only its process, never its replies: every `assistant-step` reply row stays outside whole-Turn folding, whichever Step it belongs to. Main and subagent Sessions use the same rule, without a branch on the Session kind. + +Settings → General → Replies After Collapse sits directly below When to Collapse Work Details: + +| Option | Behavior | +|---|---| +| All replies (default, `all`) | Every reply stays visible; only the process folds | +| Final answer only (`final`) | Previous rule: intermediate replies fold with the process and only the final answer stays visible | + +- The choice is saved as `ui-chat.foldedReplies` in the same Host profile settings as Work details; without a saved value it uses `all` and does not write the default. +- Switching applies immediately to rendered Turns by changing visibility only, without rebuilding rows. +- Verbose never folds a Turn, so the option is hidden in that mode. + +### What stays visible + +| Viewing state (Compact/Standard/Detailed, `all`) | Visible content | +|---|---| +| Whole Turn collapsed | Input, whole-Turn control, **every reply row** in its original order, and Nodes at the `turn` position (terminal errors, max-token notices, the completed-turn footer) | +| Whole Turn open | Process group headers and every reply row; group bodies follow the mode and manual disclosure | + +A collapsed Turn hides: + +- the reasoning rows of every Step, including the final answer's reasoning; +- tool calls, their results, and step groups; +- Nodes at the `process` position, such as model retries; +- every other process row without a group-tree position. + +A Turn with replies but no process content keeps the control's title without a collapse action. + +### What does not change + +| Aspect | Detail | +|---|---| +| Folding eligibility | Running, stopped, failed, and interleaved-input Turns offer no whole-Turn collapse; the rules for a missing start or end are unchanged | +| Whole-Turn control | One per Turn, with unchanged position and title (duration or status). Hidden step groups between replies simply disappear; no per-segment fold rows appear | +| Collapse timing | On completion, On next message, and the exchange exemption are unchanged | +| Manual disclosure | Still saved per answer generation | +| Footer, branch, and copy | Attached to the Turn's last settled Assistant message with text (`closing` of `turn-tail`), independent of whole-Turn folding | + +### Implementation + +Only the rule that decides process membership changes, and it follows the setting. `TurnProcessSpec` with its answer fields (`answerAnchorSeq`, `answerStep`, `inlineReasoning`, `messageCount`), the `latestAnswer` selection, and manual disclosure saved per answer Step stay as they were; with Final answer only they still apply and the behavior matches the previous rule. + +| Location | Content | +|---|---| +| [chat-settings.ts](../../../../packages/client/ui-chat/src/chat-settings.ts), [index.ts](../../../../packages/client/ui-chat/src/index.ts) | `FOLDED_REPLIES_FIELD`, `FOLDED_REPLIES = ['all', 'final']`, and `DEFAULT_FOLDED_REPLIES = 'all'`, registered in `ChatSettingsFields` and the Host `Config` | +| [transcript-view.ts](../../../../packages/client/ui-chat/src/client/transcript-view.ts) | The `foldedReplies` snapshot and `setFoldedReplies` of `TranscriptViewPolicy`, read and written like `collapseTiming` | +| [presentation-policy.ts](../../../../packages/client/ui-chat/src/client/presentation-policy.ts) | `ChatPresentationPolicy.foldedReplies`; `presentationPolicyFor(mode, timing, replies)` returns one fixed object for each of the 16 combinations; `derivePresentationPolicy` subscribes to all three preferences | +| [apply.ts](../../../../packages/client/ui-chat/src/client/apply.ts), [settings/TranscriptViewRow.tsx](../../../../packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx), [locale.ts](../../../../packages/client/ui-chat/src/client/locale.ts) | The selector below collapse timing, not rendered in Verbose; Chinese and English copy | +| [chat/ChatNodeSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) | With `foldedReplies === 'all'`, a `replyRow` (the non-reasoning part of an `assistant-step` with reply content) is not a `processMember` and owns the disclosure (`ownsDisclosure`) to read `foldable`/`open`; collapsible content uses `hasNonReplyProcess` or `hasExternalProcess` according to the setting | +| [chat/AssistantNodeView.tsx](../../../../packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx) | With `all`, a folded Turn hides the inline reasoning of every reply row; with `final`, only the final answer's inline reasoning | +| [conversation-nodes/turn-process-presentation.ts](../../../../packages/client/ui-chat/src/client/conversation-nodes/turn-process-presentation.ts) | `hasNonReplyProcess` beside `hasExternalProcess`: an `assistant-step` from a Step other than the answer's counts as process content only through its reasoning | + +The [Chat Node business rules](../../../../packages/client/ui-chat/src/client/conversation-nodes/README.md) describe the reader-visible rule and setting; [Chat group-tree positions and exchanges](../architecture/2026-10-11-chat-group-tree-exchanges.md) records that `assistant-step` reply rows stay outside whole-Turn folding. + +## Alternatives considered + +**Keep showing only the final answer.** The final answer is chosen by position. When a Turn continues after answering, the reader loses the real answer. + +**Distinguish by message structure: show only replies without tool calls, except messages that call `present` or `ask_user_question`.** It removes part of the redundancy while keeping most of the effective content, but it needs a maintained list of tool-name exceptions, a fine-grained rule. + +**Distinguish by a length threshold: keep folding short replies that precede a tool call.** It removes most short previews, but the threshold has no general basis, and Chinese and English character counts are not comparable. + +**Keep the previous rule for subagent Sessions.** ui-chat would need to read `sessions.subagentAddress()` and branch on the Session kind. Subagent replies are more fragmented, so showing all of them adds more redundancy, but those views are rarely read directly, and a reader who opens one usually wants the process; subagent tasks that end with a structured result showed no text under the previous rule and now show the summary written before submitting the result. + +**Give each process segment its own fold row.** Replies split the process into segments, adding many fold rows. The whole-Turn control already opens and closes the entire process at once. + +**Also delete the final-answer structure (the `answerStep` fields, `latestAnswer`, and per-generation manual disclosure).** Less code, but a larger change, and Final answer only could no longer be offered. + +**Change the default rule without a setting.** Readers who prefer the previous presentation would have no way back; the previous rule's code remains anyway, so a preference adds only one read/write path. + +**Bind the option to the Work details mode (for example, Compact = final answer only).** No new settings row, but two independent choices would be tied together and could not be switched separately. + +**Merge short action previews into the title of the following step group.** No text is lost and no row is added, but group titles need their own design, outside this change. + +## Consequences + +- Gains: Turns that receive a notice, record keeping, or a tool call after answering no longer lose the answer; subagent tasks that end with a structured result are no longer blank; readers who want the previous presentation can switch back with one choice. +- Collapsed text grows by an estimated 10% in main Sessions and 20% in subagent views. The added text includes actual conclusions that previously folded away as well as redundancy from one- or two-line action previews; subagent views are rarely read, so the absolute cost is small. +- After collapsing, consecutive replies no longer show the process between them and may read as one passage; each reply remains its own row with the original spacing. +- A collapsed Turn no longer gives its final answer the control's 16px clearance: in both choices, a reply separated from the control by folded rows keeps the 12px reply gap, and only a row directly following the control receives 16px. Because no reply changes its spacing when the fold finishes, the final answer no longer moves down about 4px at the end of a fold (#6323). +- The retained answer structure leaves one small asymmetry under `all`: `data-turn-process-messages` still counts the replies before the final answer although they are visible. +- These proportions are rough estimates and vary with ways of working and models. + +## Testing + +- `packages/client/ui-chat/tests/chat-view.client.spec.tsx`: replies from every Step stay visible while reasoning and tools fold, a Turn without a text-only ending still shows its replies, and a reply-only Turn offers no collapse; switching between `all` and `final` does not rebuild rows; cases that used an intermediate reply as a process stand-in now use reasoning-only rows. +- `packages/client/ui-chat/tests/presentation-policy.client.spec.tsx`: fixed policy objects for the 16 combinations and subscriptions to all three preferences. +- `packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts`, `transcript-view-row.client.spec.tsx`, and `config.host.spec.ts`: default, persistence, and adoption; the settings row's position, Chinese and English copy, and hiding in Verbose; Host configuration validation and restoration. +- Web e2e: `turn-tail-actions.e2e.ts` and `chat-flow-layout.e2e.ts` assert that the first visible reply after a folded control keeps the 12px reply gap; the settings and folded-Turn aria goldens include the new settings row and intermediate replies. diff --git a/.agents/notes/implemented/feature/2026-10-15-chat-keep-assistant-replies.zh.md b/.agents/notes/implemented/feature/2026-10-15-chat-keep-assistant-replies.zh.md new file mode 100644 index 0000000000..c65bb4d543 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-15-chat-keep-assistant-replies.zh.md @@ -0,0 +1,111 @@ +# Agent Note: 整轮折叠保留全部 Assistant 回复 + +Status: implemented + +[English](2026-10-15-chat-keep-assistant-replies.md) | 中文 + +## Problem + +简洁、标准、详细模式下,正常结束的 Turn 会整轮折叠。原规则下读者只看到输入、整轮控件、**最终答案**和页脚。最终答案按位置选取,即最后一个 Step 里最后一条有可见回复、且不带工具调用块的 Assistant 消息([turn-process.ts](../../../../packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts) 的 `latestAnswer`);在它之前的阶段回复和过程一起被隐藏。 + +位置不等于内容。回答之后 Turn 往往还会继续:子 agent 结束的通知唤醒一步,Agent 记账、收尾,或回答后又调用了工具。这时最后一条变成一句简短补充,或者根本没有纯文字收尾,真正的回答被折进了过程(#6132、#5429)。例如一段很长的调研汇总之后,子 agent 结束的通知又唤醒一步,最后一条只写了一句补充;读者看到的就只有这句补充。 + +我们对阶段回复的分布做了粗略估计。以下比例是量级判断,不是统计结果: + +- 主会话里,最后一条正文之前的阶段回复约占 Assistant 正文的 10%;子 agent 会话的阶段回复更碎,约占 20%。 +- 按条数算,一半以上是一两行的"接下来要做什么",全部显示会带来冗余。 +- 少数较长的阶段回复装着实际结论或交付内容。原规则下它们随过程被折叠,直接影响读者理解问题。 +- 一部分子任务以结构化结果的工具调用结束,没有纯文字回复,原规则下整轮看不到任何文字。 + +## Decision + +整轮收起默认只隐藏过程,不再隐藏回复:所有 `assistant-step` 的回复行都留在整轮折叠之外,不论它属于哪个 Step。主会话和子 agent 会话使用同一规则,不按会话类型分支。 + +设置 → 通用设置的「收起后显示的回复」位于「工作步骤收起时机」正下方: + +| 选项 | 行为 | +|---|---| +| 全部回复(默认,`all`) | 回复全部可见,只收起过程 | +| 仅最终回答(`final`) | 原规则:阶段回复随过程一起收起,只露最终答案 | + +- 选择以 `ui-chat.foldedReplies` 保存到与「工作步骤展示」相同的 Host profile 设置;未保存时按 `all`,不自动写入默认值。 +- 切换立即作用于已渲染的 Turn,只改显隐,不重建行。 +- 完全展开模式从不折叠 Turn,该选项在这一模式下隐藏。 + +### 可见范围 + +| 查看状态(简洁/标准/详细,`all`) | 可见内容 | +|---|---| +| 整轮收起 | 输入、整轮控件、按原顺序排列的**全部回复行**、`turn` 位置的节点(终止错误、最大 token 提示、已完成轮次页脚) | +| 整轮展开 | 过程组头与全部回复行;组正文按模式与手动开合显示 | + +整轮收起时隐藏的内容: + +- 所有 Step 的推理行,包括最终答案附带的推理; +- 工具调用与结果、步骤组; +- `process` 位置的节点(如模型重试); +- 其余没有分组树位置的过程行。 + +只有回复、没有任何过程内容的 Turn,整轮控件保留标题,不提供折叠操作。 + +### 不变的部分 + +| 方面 | 说明 | +|---|---| +| 折叠资格 | 运行中、已停止、失败、过程开始后有可见输入的 Turn 不提供整轮收起;缺起点、缺结束记录的规则不变 | +| 整轮控件 | 每个 Turn 一个,位置和标题(时长或状态)不变。回复之间被隐藏的步骤组直接消失,不为每段过程单独生成折叠行 | +| 收起时机 | 「完成时收起」「下次发送时收起」与交互段例外不变 | +| 手动开合 | 仍按答案世代保存 | +| 页脚、分支、复制 | 挂在本 Turn 最后一条带文字的已结算 Assistant 消息上(`turn-tail` 的 `closing`),与整轮折叠无关 | + +### 实现 + +只改"谁是过程成员"这一条规则,并让它跟随设置。`TurnProcessSpec` 及其答案字段(`answerAnchorSeq`、`answerStep`、`inlineReasoning`、`messageCount`)、`latestAnswer` 选取、按答案 Step 保存的手动开合保持原样;选「仅最终回答」时这些结构照旧生效,行为与原规则一致。 + +| 位置 | 内容 | +|---|---| +| [chat-settings.ts](../../../../packages/client/ui-chat/src/chat-settings.ts)、[index.ts](../../../../packages/client/ui-chat/src/index.ts) | `FOLDED_REPLIES_FIELD`、`FOLDED_REPLIES = ['all', 'final']`、`DEFAULT_FOLDED_REPLIES = 'all'`,登记在 `ChatSettingsFields` 与 Host `Config` | +| [transcript-view.ts](../../../../packages/client/ui-chat/src/client/transcript-view.ts) | `TranscriptViewPolicy` 的 `foldedReplies` 快照与 `setFoldedReplies`,读写方式与 `collapseTiming` 相同 | +| [presentation-policy.ts](../../../../packages/client/ui-chat/src/client/presentation-policy.ts) | `ChatPresentationPolicy.foldedReplies`;`presentationPolicyFor(mode, timing, replies)` 对 16 种组合各返回固定对象;`derivePresentationPolicy` 订阅三项偏好 | +| [apply.ts](../../../../packages/client/ui-chat/src/client/apply.ts)、[settings/TranscriptViewRow.tsx](../../../../packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx)、[locale.ts](../../../../packages/client/ui-chat/src/client/locale.ts) | 设置行在收起时机下方的选择框,完全展开模式下不渲染;中英文案 | +| [chat/ChatNodeSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) | `foldedReplies === 'all'` 时的 `replyRow`(`assistant-step` 中非推理部分且有回复内容的行)不是 `processMember`,并拥有开合状态(`ownsDisclosure`)以读取 `foldable`/`open`;可折叠内容按设置取 `hasNonReplyProcess` 或 `hasExternalProcess` | +| [chat/AssistantNodeView.tsx](../../../../packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx) | `all` 时整轮收起隐藏回复行自带的内联推理;`final` 时只隐藏最终答案的内联推理 | +| [conversation-nodes/turn-process-presentation.ts](../../../../packages/client/ui-chat/src/client/conversation-nodes/turn-process-presentation.ts) | `hasExternalProcess` 之外的 `hasNonReplyProcess`:非答案 Step 的 `assistant-step` 只有带推理时才算过程内容 | + +[Chat 节点业务规则](../../../../packages/client/ui-chat/src/client/conversation-nodes/README.zh.md)描述读者可见的规则与设置;[Chat 分组树位置与交互段](../architecture/2026-10-11-chat-group-tree-exchanges.zh.md)记录 `assistant-step` 回复行留在整轮折叠之外。 + +## Alternatives considered + +**保持只露最终答案。** 最终答案按位置选取。回答之后 Turn 还在继续时,读者会丢掉真正的回答。 + +**按消息结构区分:只露不带工具调用的回复,带 `present`、`ask_user_question` 的消息例外。** 能去掉一部分冗余,同时保住大部分有效内容,但需要按工具名维护例外名单,属于微观规则。 + +**按字数阈值区分:较短且紧跟工具调用的回复继续折叠。** 能去掉大部分短预告,但阈值没有通用依据,中英文字数也不可比。 + +**子 agent 会话保持原规则。** ui-chat 需要新读 `sessions.subagentAddress()` 并按会话类型分支。子 agent 的阶段回复更碎,全部显示后冗余更多,但这类视图很少被直接阅读,读者点进去通常就是想看过程;以结构化结果收尾的子任务原规则下整轮空白,改后能看到提交结果前写的总结。 + +**每段过程各给一个折叠行。** 回复把过程切成多段,会多出大量折叠行。整轮控件已经能一次开合全部过程。 + +**同时删除最终答案结构(`answerStep` 等字段、`latestAnswer`、按答案世代的手动开合)。** 代码更少,但改动面大,也无法再提供「仅最终回答」选项。 + +**不提供设置,只改默认规则。** 想看原呈现的用户没有退路;原规则的代码本来就保留,加一个偏好只多一条读写链。 + +**把选项绑到「工作步骤展示」模式上(例如简洁 = 仅最终回答)。** 不需要新设置行,但两件独立的事被绑在一起,不能单独切换。 + +**把短的动作预告并入后续步骤组的标题。** 不丢文字,也不占行,但需要单独设计组标题,不在本次范围内。 + +## Consequences + +- 收益:回答之后又有通知、记账或工具调用的 Turn 不再丢失回答;以结构化结果收尾的子任务不再整轮空白;想要原呈现的读者可以一键切回。 +- 收起后的正文按估计在主会话多出约 10%,在子 agent 视图多出约 20%。多出的内容里既有原本被折掉的实际结论,也有一两行动作预告带来的冗余;子 agent 视图阅读频率低,代价按绝对量计较小。 +- 收起后连续的回复之间不再显示过程,几条回复可能被连读成一段;每条回复仍是独立行,保持原有间距。 +- 收起的 Turn 不再给最终答案额外的控件 16px 间距:两种选项下,与控件之间隔着已收起行的回复都保持 12px 回复间距,只有紧跟控件的那一行得到 16px。折叠结束时没有回复改变间距,最终答案不再在折叠末尾下移约 4px(#6323)。 +- 保留的答案结构在 `all` 下留下一处小的不对称:`data-turn-process-messages` 仍统计最终答案之前的回复数,尽管它们已可见。 +- 上述比例是粗略估计,不同的工作方式和模型下会有差异。 + +## Testing + +- `packages/client/ui-chat/tests/chat-view.client.spec.tsx`:收起后各 Step 的回复可见、推理与工具收起、无纯文字收尾的 Turn 仍显示回复、只有回复的 Turn 不提供折叠;`all` 与 `final` 实时切换不重建行;把阶段回复当作过程替身的用例改为纯推理行。 +- `packages/client/ui-chat/tests/presentation-policy.client.spec.tsx`:16 种组合的固定策略对象与三项偏好订阅。 +- `packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts`、`transcript-view-row.client.spec.tsx`、`config.host.spec.ts`:默认值、持久化与采纳,设置行位置、中英文案与完全展开时隐藏,Host 配置校验与恢复。 +- Web e2e:`turn-tail-actions.e2e.ts` 与 `chat-flow-layout.e2e.ts` 断言收起后控件之后第一条可见回复保持 12px 回复间距;设置页与收起后 aria 黄金快照包含新设置行与阶段回复。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0331b5b6ed..41f28c8e7e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -570,12 +570,15 @@ jobs: - name: Parse Windows runner supervision scripts shell: pwsh run: | - foreach ($path in @('apps/desktop/scripts/windows-runner-task.ps1', 'apps/desktop/scripts/run-windows-runner-task.ps1', 'apps/desktop/tests/windows-runner-task.ps1')) { + foreach ($path in @('apps/desktop/scripts/windows-runner-task.ps1', 'apps/desktop/scripts/run-windows-runner-task.ps1', 'apps/desktop/tests/windows-runner-task.ps1', 'apps/desktop/tests/windows-runner-launcher.ps1')) { $tokens = $null $parseErrors = $null $null = [Management.Automation.Language.Parser]::ParseFile((Join-Path $env:GITHUB_WORKSPACE $path), [ref]$tokens, [ref]$parseErrors) if ($parseErrors.Count) { throw ($parseErrors -join "`n") } } + - name: Verify windowless Windows runner launcher + shell: pwsh + run: ./apps/desktop/tests/windows-runner-launcher.ps1 - name: Run Windows observational gates on built artifacts id: observational continue-on-error: true diff --git a/.github/workflows/desktop-publish.yml b/.github/workflows/desktop-publish.yml index ba8f5b0896..3e5c006d7b 100644 --- a/.github/workflows/desktop-publish.yml +++ b/.github/workflows/desktop-publish.yml @@ -74,6 +74,9 @@ jobs: - uses: actions/setup-node@v7.0.0 with: node-version: '24' + - name: Check local publication configuration before dependency installation + working-directory: desktop-publisher + run: node apps/desktop/scripts/preflight-desktop-publish-local.mjs - run: pnpm install --frozen-lockfile working-directory: desktop-publisher - name: Resolve retained package and approval environment @@ -115,6 +118,9 @@ jobs: - uses: actions/setup-node@v7.0.0 with: node-version: '24' + - name: Check local publication configuration before dependency installation + working-directory: desktop-publisher + run: node apps/desktop/scripts/preflight-desktop-publish-local.mjs - run: pnpm install --frozen-lockfile working-directory: desktop-publisher - name: Verify local package without upload credentials diff --git a/apps/desktop/publishing/ci/README.i18n.yaml b/apps/desktop/publishing/ci/README.i18n.yaml index a16c1bc513..eec80bdac1 100644 --- a/apps/desktop/publishing/ci/README.i18n.yaml +++ b/apps/desktop/publishing/ci/README.i18n.yaml @@ -12,14 +12,20 @@ en: 470bdf1487a739f9 zh: cd80330eaa8f730c /desktop-ci-publication/table-of-contents: - en: 1b6a4d7146879eec - zh: f2520fc412cd2d2a + en: 34283bf8397f6836 + zh: 1798bd921c3adf6c +/desktop-ci-publication/setup-sequence: + en: 2be541ef35a092a4 + zh: 77d87c00deb45758 +/desktop-ci-publication/runner-labels-and-queue-diagnosis: + en: 0478d8e35f9a3d55 + zh: 0aa3fd828bc02691 /desktop-ci-publication/machine-setup: en: f80388c2b1ffef4c zh: f242ab2316bf38a1 /desktop-ci-publication/package-and-publish: - en: 0a26537845e89dda - zh: 307378bb65e24650 + en: f7c12d343891e547 + zh: da596ee1366c2046 /desktop-ci-publication/recovery-and-records: en: 2e1e6b16d0d6bd41 zh: 6ff4ad5a8f1f57b4 diff --git a/apps/desktop/publishing/ci/README.md b/apps/desktop/publishing/ci/README.md index 0028260f96..9835027926 100644 --- a/apps/desktop/publishing/ci/README.md +++ b/apps/desktop/publishing/ci/README.md @@ -12,11 +12,42 @@ Publish a completed package from its original machine without downloading its in ## Table of Contents +- [Setup sequence](#setup-sequence) +- [Runner labels and queue diagnosis](#runner-labels) - [Machine setup](#machine-setup) - [Package and publish](#package-and-publish) - [Recovery and records](#recovery-and-records) - [Restore a channel](#restore-a-channel) + + +## Setup sequence + +Use this sequence when onboarding a dedicated machine. The deployment operator owns registration, credentials, approvals and real upload acceptance; repository checks do not establish that a machine can publish. + +1. Prepare a runner-owned checkout and an external configuration directory. Follow [Windows machine setup and signing](../../windows-signing/README.md#machine-setup) or [macOS runner and signing setup](../../packaging/README.md#macos-actions) for tools, templates and platform credentials. +2. Register the runner using GitHub **Settings → Actions → Runners**, or the organization's runner administration page. Record its exact registered name, grant this repository access to its runner group, and assign the [required labels](#runner-labels). Keep the default labels during registration. +3. Install and start the platform supervisor described in the platform guide. Windows Task Scheduler and the macOS LaunchAgent require the configured user's login; they do not provide unattended startup before login. Confirm both GitHub connectivity and `Listening for Jobs`, then check the runner is online in GitHub. +4. Complete [machine publication configuration](#machine-setup), the repository's `DESKTOP_PUBLISH_RUNNERS` variable, and the two deployment Environments. The repository administrator owns routing and reviewers; the COS administrator owns bucket configuration and object read/write permissions; production also needs the configured GitHub App. +5. Run a signed Windows or notarized macOS **test** package using [Package and publish](#package-and-publish). Confirm the packaging job used the intended registered runner and retained its files. Dispatch publication from `master` with that run ID and attempt; confirm `prepare` actually starts on the same machine and produces the approval summary. +6. Inspect the summary's environment, version, channel and URLs before approving the test upload. Verify the publication record, public installer download and update feed. Test acceptance does not establish production permissions: provision and approve production separately. Keep the run/attempt and acceptance results for recovery. + + + +## Runner labels and queue diagnosis + +GitHub matches every label in a job's `runs-on` list. The [default-label rules](https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/use-in-a-workflow) explain why registration with `--no-default-labels` can leave an online runner ineligible. The current workflows require: + +| Job | Required labels on the same runner | +| --- | --- | +| Windows packaging | `dsh-win-package-trial` | +| macOS packaging | `self-hosted`, `macOS`, `ARM64`, `dsh-mac-package-trial` | +| Publication `prepare` and `publish` | `self-hosted` and the selected `DESKTOP_PUBLISH_RUNNERS` entry's `label` | + +A runner with only `dsh-win-package-trial` can package Windows but cannot accept publication. Its routing entry does not add labels to its GitHub registration. Have a runner administrator correct the registered labels; changing the route's `target` or restarting a healthy listener does not supply a missing label. + +For a queued job, compare the job page's requested labels with **Settings → Actions → Runners**, then check repository/group access and whether the matching runner is online and idle. A running supervisor only establishes that its process exists. A successful hosted `route` job only establishes package selection; it does not establish that `prepare` can be assigned. A deployment waiting for approval needs its Environment reviewer, while an assigned job that fails needs its step logs. Diagnose these states separately before restarting anything. + ## Machine setup @@ -41,7 +72,7 @@ Select the platform's `Package Desktop` workflow, preset, source, channel and ve Packaging only retains files. After success, open **Publish Desktop · COS → Run workflow** on `master`, enter `package_run_id` from the packaging run URL, and optionally enter `package_run_attempt`. Blank selects the latest attempt, which must have succeeded; it never falls back to an older success. An explicit attempt selects that exact successful build. Windows must be signed; macOS must finish notarization and stapling. GitHub job evidence selects the original runner; its local manifest supplies the preset, version, source and channel before approval. No installer or request artifact is downloaded. Retained packages with the old `publishRequested=false` flag remain eligible; missing or changed local files stop publication. -Before uploading, the publisher hashes all retained files, checks version and destination metadata, and rechecks the platform signatures. macOS verifies the DMG and extracts the ZIP to verify the app's ticket; Windows checks Authenticode, the configured signer thumbprint and the timestamp. Preparation writes a Chinese approval summary with separate version, channel, platform and deployment fields, the retained test release ID, full installer/feed URLs, a link to the original packaging attempt, source ref/SHA, platform verification results and proposed tag. Folded details include the COS bucket and file sizes/SHA-256 of the upload bytes, including rewritten YAML. Public-repository summaries expose the test prefix and download URLs; the prefix is not authentication. Both publishing jobs wait for **Review deployments → Approve and deploy**, then recheck the approved manifest and upload plan digest. Changed bytes or destinations reject. Approval cannot edit a version; reject and rebuild instead. Both deployments upload missing binaries, verify remote bytes, then update YAML. There is no files-only mode in this workflow. Production finally creates `desktop-v` at the recorded packaging commit; an existing tag must name the same commit. +Before installing dependencies on the original runner, preparation and the approved publish job check that the selected `publishing/.json` is readable JSON. Missing configuration reports its path; restore it and retry publication with the same package. Full configuration and signature validation still run afterward. Before uploading, the publisher hashes all retained files, checks version and destination metadata, and rechecks the platform signatures. macOS verifies the DMG and extracts the ZIP to verify the app's ticket; Windows checks Authenticode, the configured signer thumbprint and the timestamp. Preparation writes a Chinese approval summary with separate version, channel, platform and deployment fields, the retained test release ID, full installer/feed URLs, a link to the original packaging attempt, source ref/SHA, platform verification results and proposed tag. Folded details include the COS bucket and file sizes/SHA-256 of the upload bytes, including rewritten YAML. Public-repository summaries expose the test prefix and download URLs; the prefix is not authentication. Both publishing jobs wait for **Review deployments → Approve and deploy**, then recheck the approved manifest and upload plan digest. Changed bytes or destinations reject. Approval cannot edit a version; reject and rebuild instead. Both deployments upload missing binaries, verify remote bytes, then update YAML. There is no files-only mode in this workflow. Production finally creates `desktop-v` at the recorded packaging commit; an existing tag must name the same commit. diff --git a/apps/desktop/publishing/ci/README.zh.md b/apps/desktop/publishing/ci/README.zh.md index c6165845b5..f8c41b6d95 100644 --- a/apps/desktop/publishing/ci/README.zh.md +++ b/apps/desktop/publishing/ci/README.zh.md @@ -12,11 +12,42 @@ description: "在专用 macOS 和 Windows runner 上发布留存的 Desktop 安 ## 目录 +- [配置顺序](#setup-sequence) +- [Runner 标签与排队排查](#runner-labels) - [机器配置](#machine-setup) - [打包与发布](#package-and-publish) - [恢复与记录](#recovery-and-records) - [恢复频道](#restore-a-channel) + + +## 配置顺序 + +首次配置专用机器时按此顺序操作。部署操作员负责注册、凭据、审批和真实上传验收;仓库检查不能证明机器已具备发布能力。 + +1. 准备 runner 专用 checkout 和仓库外的配置目录。按 [Windows 机器与签名配置](../../windows-signing/README.zh.md#machine-setup)或 [macOS runner 与签名配置](../../packaging/README.zh.md#macos-actions)准备工具、模板和平台凭据。 +2. 通过 GitHub **Settings → Actions → Runners** 或组织的 runner 管理页面注册 runner。记录准确的注册名称,允许本仓库访问其 runner group,并分配[必需标签](#runner-labels)。注册时保留默认标签。 +3. 按平台指南安装并启动守护任务。Windows 计划任务和 macOS LaunchAgent 都要求配置的用户登录,不能在登录前无人值守启动。确认已连接 GitHub 且出现 `Listening for Jobs`,再检查 GitHub 中 runner 在线。 +4. 完成[本机发布配置](#machine-setup)、仓库变量 `DESKTOP_PUBLISH_RUNNERS` 和两个部署 Environment。仓库管理员负责路由与审批人;COS 管理员负责桶配置及对象读写权限;production 还需要配置 GitHub App。 +5. 按[打包与发布](#package-and-publish)完成一次 Windows 已签名或 macOS 已公证的 **test** 打包。确认打包 job 使用预期的注册 runner 并留存文件。从 `master` 发起发布,填写该 run ID 和 attempt;确认 `prepare` 确实在同一台机器上启动并生成审批摘要。 +6. 审核摘要中的环境、版本、频道和 URL 后,再批准测试上传。验证发布记录、公开安装包下载和升级 feed。test 验收不能证明 production 权限可用:production 需独立配置与审批。保留 run/attempt 和验收结果,供故障恢复使用。 + + + +## Runner 标签与排队排查 + +GitHub 要求 runner 同时匹配 job 的 `runs-on` 列表中的全部标签。[默认标签规则](https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/use-in-a-workflow)说明了为什么使用 `--no-default-labels` 注册后,runner 即使在线也可能无法接单。当前工作流要求: + +| Job | 同一台 runner 必须具备的标签 | +| --- | --- | +| Windows 打包 | `dsh-win-package-trial` | +| macOS 打包 | `self-hosted`、`macOS`、`ARM64`、`dsh-mac-package-trial` | +| 发布 `prepare` 和 `publish` | `self-hosted`,以及选中的 `DESKTOP_PUBLISH_RUNNERS` 条目中的 `label` | + +只有 `dsh-win-package-trial` 的 runner 可以打包 Windows,却不能接发布任务。路由条目不会给 GitHub 中注册的 runner 添加标签。需由 runner 管理员修正注册标签;修改路由的 `target` 或重启正常监听的进程都不能补齐缺失标签。 + +job 排队时,先对照 job 页面请求的标签与 **Settings → Actions → Runners** 中的标签,再检查仓库/group 访问权限,以及匹配的 runner 是否在线且空闲。守护任务运行只说明进程存在。托管的 `route` job 成功只说明已选定打包任务,不代表 `prepare` 能被分配。部署等待审批时需要 Environment 审批人操作,已经分配但失败的 job 则需查看步骤日志。重启前先区分这些状态。 + ## 机器配置 @@ -41,7 +72,7 @@ Environment 审批限制此工作流,不限制同一机器账户运行的任 打包只保留文件。成功后,在 `master` 上打开 **Publish Desktop · COS → Run workflow**,填写打包运行 URL 中的 `package_run_id`,并可填写 `package_run_attempt`。留空选择最新尝试,且该尝试必须成功,不会回退到较早的成功尝试;显式填写则选择对应的成功构建。Windows 必须完成签名,macOS 必须完成公证和 stapling。GitHub job 证据确定原 runner,本机清单在审批前提供 preset、版本、源码和通道。不会下载安装包或请求 artifact。旧清单的 `publishRequested=false` 不影响发布资格;本机文件缺失或变化会停止发布。 -上传前,发布器校验全部留存文件的哈希、版本及目标信息,再复核平台签名。macOS 校验 DMG,并解压 ZIP 校验 app 票据;Windows 校验 Authenticode、配置的签名证书指纹及时间戳。准备阶段生成中文审批摘要,分别展示版本、通道、平台和环境,以及留存的 test release ID、安装包与升级 YAML 的完整 URL、原打包尝试链接、源码 ref/SHA、平台校验结果及拟创建的 tag。折叠详情包含 COS 存储桶,以及实际上传字节的大小和 SHA-256(包括改写后的 YAML)。公开仓库的摘要会公开测试前缀和下载 URL;前缀不提供访问鉴权。两种发布 job 均等待 **Review deployments → Approve and deploy**,随后重新核对已批准的 manifest 与上传计划摘要。文件或目标变化会被拒绝。审批不能修改版本;应拒绝并重新打包。两种环境都上传缺失文件、校验远端字节,再更新 YAML。此工作流没有只上传文件的模式。production 最后为记录的打包提交创建 `desktop-v`;已有 tag 必须指向同一提交。 +在原 runner 安装依赖前,准备阶段和审批后的发布 job 都会检查所选 `publishing/.json` 是否为可读取的 JSON。配置缺失时会报告路径;补齐后可使用同一个包重试发布。之后仍会执行完整配置与签名校验。上传前,发布器校验全部留存文件的哈希、版本及目标信息,再复核平台签名。macOS 校验 DMG,并解压 ZIP 校验 app 票据;Windows 校验 Authenticode、配置的签名证书指纹及时间戳。准备阶段生成中文审批摘要,分别展示版本、通道、平台和环境,以及留存的 test release ID、安装包与升级 YAML 的完整 URL、原打包尝试链接、源码 ref/SHA、平台校验结果及拟创建的 tag。折叠详情包含 COS 存储桶,以及实际上传字节的大小和 SHA-256(包括改写后的 YAML)。公开仓库的摘要会公开测试前缀和下载 URL;前缀不提供访问鉴权。两种发布 job 均等待 **Review deployments → Approve and deploy**,随后重新核对已批准的 manifest 与上传计划摘要。文件或目标变化会被拒绝。审批不能修改版本;应拒绝并重新打包。两种环境都上传缺失文件、校验远端字节,再更新 YAML。此工作流没有只上传文件的模式。production 最后为记录的打包提交创建 `desktop-v`;已有 tag 必须指向同一提交。 diff --git a/apps/desktop/scripts/preflight-desktop-publish-local.d.mts b/apps/desktop/scripts/preflight-desktop-publish-local.d.mts new file mode 100644 index 0000000000..12c762b599 --- /dev/null +++ b/apps/desktop/scripts/preflight-desktop-publish-local.d.mts @@ -0,0 +1,6 @@ +/** + * Check the selected machine configuration without authorizing publication or reading secrets. + * @param env Selected package or approved route and machine directories. + * @returns Resolves when the selected configuration is a readable JSON object; full validation remains in the publisher. + */ +export function preflightDesktopPublishLocal(env: NodeJS.ProcessEnv): Promise diff --git a/apps/desktop/scripts/preflight-desktop-publish-local.mjs b/apps/desktop/scripts/preflight-desktop-publish-local.mjs new file mode 100644 index 0000000000..bce57b53d4 --- /dev/null +++ b/apps/desktop/scripts/preflight-desktop-publish-local.mjs @@ -0,0 +1,43 @@ +/** Reject missing local publication configuration before dependency installation; never open credential files. */ +import { readFile } from 'node:fs/promises' +import { dirname, isAbsolute, join, resolve } from 'node:path' + +async function readJson(path, subject) { + try { + return JSON.parse(await readFile(path, 'utf8')) + } catch { + throw new Error(`desktop publish: ${subject} is missing, unreadable or invalid JSON: ${path}. Restore this file on the original runner, then retry publication; repackaging is not required.`) + } +} + +/** + * Check the selected machine configuration without authorizing publication or reading secrets. + * @param {NodeJS.ProcessEnv} env Selected package or approved route and machine directories. + * @returns {Promise} Resolves when the selected configuration is a readable JSON object; full validation remains in the publisher. + */ +export async function preflightDesktopPublishLocal(env) { + const root = env.DSH_PACKAGING_CONFIG_DIR + if (!root || !isAbsolute(root)) throw new Error('desktop publish: set DSH_PACKAGING_CONFIG_DIR to the absolute machine packaging configuration directory') + let preset + if (env.DESKTOP_PUBLISH_ROUTE) { + preset = JSON.parse(env.DESKTOP_PUBLISH_ROUTE).preset + } else { + const selection = JSON.parse(env.DESKTOP_PACKAGE_SELECTION || '{}') + const run = selection?.run + if (!Number.isSafeInteger(run?.id) || run.id <= 0 || !Number.isSafeInteger(run?.run_attempt) || run.run_attempt <= 0) { + throw new Error('desktop publish: selected package must identify a positive run ID and attempt') + } + const releases = env.DSH_DESKTOP_RELEASES_DIR || join(dirname(root), 'releases') + if (!isAbsolute(releases)) throw new Error('desktop publish: DSH_DESKTOP_RELEASES_DIR must be absolute') + const manifest = await readJson(join(releases, String(run.id), String(run.run_attempt), 'manifest.json'), 'retained package manifest') + preset = manifest?.source?.preset + } + if (preset !== 'test' && preset !== 'production') throw new Error('desktop publish: selected package preset must be test or production') + const path = join(root, 'publishing', `${preset}.json`) + const config = await readJson(path, 'publication configuration') + if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error(`desktop publish: publication configuration must be a JSON object: ${path}`) +} + +if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) { + await preflightDesktopPublishLocal(process.env) +} diff --git a/apps/desktop/scripts/run-windows-runner-task.ps1 b/apps/desktop/scripts/run-windows-runner-task.ps1 index 1fe68ad15b..339350377a 100644 --- a/apps/desktop/scripts/run-windows-runner-task.ps1 +++ b/apps/desktop/scripts/run-windows-runner-task.ps1 @@ -5,7 +5,6 @@ $digest = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData([Text. $taskName = 'DSH-Packaging-' + $digest.Substring(0,16) $log = Join-Path $PSScriptRoot ('run-' + [DateTime]::UtcNow.ToString('yyyyMMddTHHmmssfffZ') + '-' + $PID + '.log') try { - @{ Pid = $PID; Started = (Get-Process -Id $PID).StartTime.ToUniversalTime().Ticks } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $PSScriptRoot 'process.json') $config = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'config.json') -Raw | ConvertFrom-Json $attemptFile = Join-Path $PSScriptRoot 'attempts.txt' $attempts = [int](Get-Content -LiteralPath $attemptFile -Raw) diff --git a/apps/desktop/scripts/windows-runner-launcher.cs b/apps/desktop/scripts/windows-runner-launcher.cs new file mode 100644 index 0000000000..638b50065d --- /dev/null +++ b/apps/desktop/scripts/windows-runner-launcher.cs @@ -0,0 +1,75 @@ +// Compiled as a Windows GUI executable so Task Scheduler never allocates a console. +using System; +using System.Diagnostics; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; + +internal static class WindowsRunnerLauncher +{ + private static int Main(string[] args) + { + string root = AppDomain.CurrentDomain.BaseDirectory; + var owner = Process.GetCurrentProcess(); + string log = Path.Combine(root, "launcher-" + DateTime.UtcNow.ToString("yyyyMMddTHHmmssfffZ") + "-" + owner.Id + "-" + Guid.NewGuid().ToString("N")); + try + { + File.WriteAllText(Path.Combine(root, "process.json"), "{\"Pid\":" + owner.Id + ",\"Started\":" + owner.StartTime.ToUniversalTime().Ticks + "}"); + if (args.Length != 1 || !Path.IsPathRooted(args[0])) + throw new ArgumentException("Expected the absolute PowerShell executable path."); + using (var output = File.Create(log + ".out.log")) + using (var errors = File.Create(log + ".err.log")) + using (var child = new Process()) + { + child.StartInfo = new ProcessStartInfo + { + FileName = args[0], + Arguments = "-NoProfile -NonInteractive -File \"" + Path.Combine(root, "run.ps1") + "\"", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + child.Start(); + Task stdout = child.StandardOutput.BaseStream.CopyToAsync(output); + Task stderr = child.StandardError.BaseStream.CopyToAsync(errors); + child.WaitForExit(); + // Descendants can retain inherited pipe writers after the direct child exits. + if (!Task.WaitAll(new[] { stdout, stderr }, TimeSpan.FromSeconds(5))) + { + File.WriteAllText(log + ".drain.log", "Output drain timed out after child exit " + child.ExitCode + "; launcher logs may be incomplete. Periodic recovery remains governed by the restart budget."); + // Process exit releases pipe reads still held by background copy tasks. + return 5; + } + return child.ExitCode; + } + } + catch (Exception error) + { + // Launcher exceptions disable periodic starts, including failures before run.ps1 can consume its budget. + int result = 2; + try { DisableTask(root); } + catch (Exception disableError) { error = new AggregateException(error, disableError); result = 4; } + try { File.AppendAllText(log + ".err.log", error.ToString() + Environment.NewLine); } + catch (Exception) + { + // No console is available. Task Scheduler result 3 reports that diagnostics could not be written. + return result == 4 ? 4 : 3; + } + return result; + } + } + + private static void DisableTask(string root) + { + // Keep task identity aligned with windows-runner-task.ps1 and run-windows-runner-task.ps1. + string runner = Directory.GetParent(root.TrimEnd(Path.DirectorySeparatorChar)).FullName.TrimEnd('\\', '/'); + string taskName; + using (var hash = SHA256.Create()) + taskName = "DSH-Packaging-" + BitConverter.ToString(hash.ComputeHash(Encoding.UTF8.GetBytes(runner.ToUpperInvariant()))).Replace("-", "").Substring(0, 16); + dynamic scheduler = Activator.CreateInstance(Type.GetTypeFromProgID("Schedule.Service")); + scheduler.Connect(); + scheduler.GetFolder("\\").GetTask(taskName).Enabled = false; + } +} diff --git a/apps/desktop/scripts/windows-runner-task.ps1 b/apps/desktop/scripts/windows-runner-task.ps1 index 65c6d08799..47efba43e0 100644 --- a/apps/desktop/scripts/windows-runner-task.ps1 +++ b/apps/desktop/scripts/windows-runner-task.ps1 @@ -37,12 +37,16 @@ if ($Action -eq 'install') { } else { $null = New-Item -ItemType Directory -Path $serviceDir } + $compiler = Join-Path $env:SystemRoot 'Microsoft.NET/Framework64/v4.0.30319/csc.exe' + if (-not (Test-Path -LiteralPath $compiler)) { throw 'Install requires the Windows .NET Framework 4.5+ compiler (Framework64/v4.0.30319/csc.exe).' } + $launcher = Join-Path $serviceDir 'launcher.exe' + & $compiler /nologo /target:winexe "/out:$launcher" (Join-Path $PSScriptRoot 'windows-runner-launcher.cs') + if ($LASTEXITCODE -ne 0) { throw 'Runner launcher compilation failed; verify .NET Framework 4.5+ is installed. No task was installed.' } Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'start-windows-packaging-runner.ps1') -Destination (Join-Path $serviceDir 'start.ps1') Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'run-windows-runner-task.ps1') -Destination (Join-Path $serviceDir 'run.ps1') @{ RunnerRoot = $RunnerRoot; ConfigRoot = $ConfigRoot; TaskName = $taskName; RestartCount = $RestartCount } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $serviceDir 'config.json') -Encoding utf8 Set-Content -LiteralPath (Join-Path $serviceDir 'attempts.txt') -Value '0' - $arguments = '-NoProfile -NonInteractive -WindowStyle Hidden -File "' + (Join-Path $serviceDir 'run.ps1') + '"' - $execution = New-ScheduledTaskAction -Execute $pwsh -Argument $arguments -WorkingDirectory $RunnerRoot + $execution = New-ScheduledTaskAction -Execute $launcher -Argument ('"' + $pwsh + '"') -WorkingDirectory $RunnerRoot $principal = New-ScheduledTaskPrincipal -UserId $sid -LogonType Interactive -RunLevel Limited $trigger = @( (New-ScheduledTaskTrigger -AtLogOn -User $sid), diff --git a/apps/desktop/tests/desktop-publish-local-preflight.spec.ts b/apps/desktop/tests/desktop-publish-local-preflight.spec.ts new file mode 100644 index 0000000000..1ce0a9306b --- /dev/null +++ b/apps/desktop/tests/desktop-publish-local-preflight.spec.ts @@ -0,0 +1,45 @@ +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it } from 'vitest' +import { preflightDesktopPublishLocal } from '../scripts/preflight-desktop-publish-local.mjs' + +it('checks the retained preset before approval and the approved preset before upload without opening credentials', async () => { + const root = await mkdtemp(join(tmpdir(), 'publish-preflight-')) + try { + const configRoot = join(root, 'packaging') + const releases = join(root, 'custom-releases') + const manifest = join(releases, '42', '2') + await mkdir(manifest, { recursive: true }) + await writeFile(join(manifest, 'manifest.json'), JSON.stringify({ source: { preset: 'test' } })) + const env = { DSH_PACKAGING_CONFIG_DIR: configRoot, DSH_DESKTOP_RELEASES_DIR: releases, + DESKTOP_PACKAGE_SELECTION: JSON.stringify({ run: { id: 42, run_attempt: 2 } }) } + await expect(preflightDesktopPublishLocal(env)).rejects.toThrow(`publication configuration is missing, unreadable or invalid JSON: ${join(configRoot, 'publishing', 'test.json')}`) + await mkdir(join(configRoot, 'publishing'), { recursive: true }) + await writeFile(join(configRoot, 'publishing', 'test.json'), JSON.stringify({ credentialsFile: join(root, 'absent-secret.json') })) + await expect(preflightDesktopPublishLocal(env)).resolves.toBeUndefined() + const approved = { ...env, DESKTOP_PUBLISH_ROUTE: JSON.stringify({ preset: 'production' }) } + await expect(preflightDesktopPublishLocal(approved)).rejects.toThrow('production.json') + await writeFile(join(configRoot, 'publishing', 'production.json'), '{secret-invalid-json') + await expect(preflightDesktopPublishLocal(approved)).rejects.toThrow('invalid JSON') + await writeFile(join(configRoot, 'publishing', 'production.json'), '[]') + await expect(preflightDesktopPublishLocal(approved)).rejects.toThrow('must be a JSON object') + await writeFile(join(configRoot, 'publishing', 'production.json'), '{}') + await expect(preflightDesktopPublishLocal(approved)).resolves.toBeUndefined() + await expect(preflightDesktopPublishLocal({ ...approved, DESKTOP_PUBLISH_ROUTE: '{"preset":"../test"}' })).rejects.toThrow('preset must be') + await expect(preflightDesktopPublishLocal({ ...env, DESKTOP_PACKAGE_SELECTION: '{"run":{"id":"../42","run_attempt":2}}' })).rejects.toThrow('positive run ID') + await expect(preflightDesktopPublishLocal({ ...env, DSH_DESKTOP_RELEASES_DIR: '' })).rejects.toThrow('retained package manifest') + await expect(preflightDesktopPublishLocal({ ...env, DSH_PACKAGING_CONFIG_DIR: 'relative' })).rejects.toThrow('absolute machine') + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +it('runs the local preflight before dependency installation in both self-hosted jobs', async () => { + const workflow = await readFile('.github/workflows/desktop-publish.yml', 'utf8') + for (const job of ['prepare', 'publish']) { + const section = workflow.split(`\n ${job}:`)[1]!.split(/\n \w+:/u)[0]! + expect(section.indexOf('node apps/desktop/scripts/preflight-desktop-publish-local.mjs')).toBeGreaterThan(0) + expect(section.indexOf('node apps/desktop/scripts/preflight-desktop-publish-local.mjs')).toBeLessThan(section.indexOf('pnpm install --frozen-lockfile')) + } +}) diff --git a/apps/desktop/tests/windows-runner-launcher.ps1 b/apps/desktop/tests/windows-runner-launcher.ps1 new file mode 100644 index 0000000000..6da51117e4 --- /dev/null +++ b/apps/desktop/tests/windows-runner-launcher.ps1 @@ -0,0 +1,90 @@ +# Windowless launcher acceptance without Task Scheduler, GitHub registration or signing credentials. +$ErrorActionPreference = 'Stop' +if (-not $IsWindows) { throw 'Requires PowerShell 7 on Windows.' } +$root = Join-Path ([IO.Path]::GetTempPath()) ('dsh-launcher-' + [guid]::NewGuid().ToString('N')) +$taskRoot = Join-Path $root 'task with spaces' +$null = New-Item -ItemType Directory -Path $taskRoot +$launcher = Join-Path $taskRoot 'launcher.exe' +$compiler = Join-Path $env:SystemRoot 'Microsoft.NET/Framework64/v4.0.30319/csc.exe' +& $compiler /nologo /target:winexe "/out:$launcher" (Join-Path $PSScriptRoot '../scripts/windows-runner-launcher.cs') +if ($LASTEXITCODE -ne 0) { throw 'Launcher compilation failed.' } +$image = [IO.File]::ReadAllBytes($launcher) +$peOffset = [BitConverter]::ToInt32($image, 0x3c) +if ([BitConverter]::ToUInt16($image, $peOffset + 24 + 68) -ne 2) { throw 'Launcher must use the Windows GUI subsystem.' } +@' +Add-Type -TypeDefinition 'using System; using System.Runtime.InteropServices; public static class ConsoleProbe { [DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow(); }' +[Console]::WriteLine('console=' + [ConsoleProbe]::GetConsoleWindow().ToInt64()) +[Console]::Error.WriteLine('launcher stderr probe') +exit 37 +'@ | Set-Content -LiteralPath (Join-Path $taskRoot 'run.ps1') +function Invoke-Launcher([string]$Executable, [int]$ExpectedExit) { + $existingLogs = @(Get-ChildItem -LiteralPath $taskRoot -Filter 'launcher-*.err.log' | ForEach-Object FullName) + $process = [Diagnostics.Process]::new() + $process.StartInfo = [Diagnostics.ProcessStartInfo]::new($launcher) + $process.StartInfo.UseShellExecute = $false + $process.StartInfo.CreateNoWindow = $true + $process.StartInfo.ArgumentList.Add($Executable) + $started = $false + $primaryFailure = $null + try { + $started = $process.Start() + if (-not $process.WaitForExit(30000)) { throw 'Launcher did not exit within 30 seconds.' } + if ($process.ExitCode -ne $ExpectedExit) { throw "Expected exit $ExpectedExit, got $($process.ExitCode)." } + $logs = @(Get-ChildItem -LiteralPath $taskRoot -Filter ('launcher-*-'+$process.Id+'-*.err.log') | Where-Object { $_.FullName -notin $existingLogs }) + if ($logs.Count -ne 1) { throw 'Expected a unique launcher diagnostic.' } + return $logs[0].FullName.Substring(0, $logs[0].FullName.Length - '.err.log'.Length) + } catch { $primaryFailure = $_; throw } finally { + try { + if ($started -and -not $process.HasExited) { + $process.Kill($true) + if (-not $process.WaitForExit(30000)) { throw 'Launcher teardown did not complete.' } + } + } catch { + if (-not $primaryFailure) { throw } + Write-Warning "Launcher cleanup also failed: $_" + } finally { + $process.Dispose() + } + } +} +$log = Invoke-Launcher (Join-Path $PSHOME 'pwsh.exe') 37 +if ((Get-Content -LiteralPath ($log + '.out.log') -Raw).Trim() -ne 'console=0') { throw 'Child allocated a console or stdout was lost.' } +if ((Get-Content -LiteralPath ($log + '.err.log') -Raw).Trim() -ne 'launcher stderr probe') { throw 'Child stderr was lost.' } +$log = Invoke-Launcher (Join-Path $root 'missing.exe') 4 +if (-not (Get-Content -LiteralPath ($log + '.err.log') -Raw).Contains('Win32Exception')) { throw 'Startup failure diagnostic was lost.' } +@' +$info = [Diagnostics.ProcessStartInfo]::new((Join-Path $PSHOME 'pwsh.exe')) +$info.UseShellExecute = $false +$info.CreateNoWindow = $true +$info.ArgumentList.Add('-NoProfile') +$info.ArgumentList.Add('-NonInteractive') +$info.ArgumentList.Add('-Command') +$info.ArgumentList.Add('Start-Sleep -Seconds 120') +$orphan = [Diagnostics.Process]::Start($info) +@{ Pid = $orphan.Id; Started = $orphan.StartTime.ToUniversalTime().Ticks } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $PSScriptRoot 'orphan.json') +exit 37 +'@ | Set-Content -LiteralPath (Join-Path $taskRoot 'run.ps1') +$orphan = $null +$primaryFailure = $null +try { + $log = Invoke-Launcher (Join-Path $PSHOME 'pwsh.exe') 5 + if (-not (Get-Content -LiteralPath ($log + '.drain.log') -Raw).Contains('child exit 37')) { throw 'Inherited writer did not exercise bounded draining.' } +} catch { $primaryFailure = $_; throw } finally { + try { + $recordFile = Join-Path $taskRoot 'orphan.json' + if (Test-Path -LiteralPath $recordFile) { + $record = Get-Content -LiteralPath $recordFile -Raw | ConvertFrom-Json + $orphan = Get-Process -Id $record.Pid -ErrorAction SilentlyContinue + if ($orphan -and $orphan.StartTime.ToUniversalTime().Ticks -eq $record.Started) { + $orphan.Kill($true) + if (-not $orphan.WaitForExit(30000)) { throw 'Orphan fixture did not exit.' } + } + } + } catch { + if (-not $primaryFailure) { throw } + Write-Warning "Orphan cleanup also failed: $_" + } finally { + if ($orphan) { $orphan.Dispose() } + } +} +Write-Output "PASS: GUI executable, no child console, exit propagation, stdout/stderr, startup failure and bounded inherited-pipe drain. Evidence: $root" diff --git a/apps/desktop/tests/windows-runner-task.ps1 b/apps/desktop/tests/windows-runner-task.ps1 index 1e4a03f2a4..da9a8ff8ac 100644 --- a/apps/desktop/tests/windows-runner-task.ps1 +++ b/apps/desktop/tests/windows-runner-task.ps1 @@ -7,11 +7,13 @@ $source = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../scripts')) $runner = Join-Path $root 'runner with spaces' $null = New-Item -ItemType Directory -Path $runner Set-Content -LiteralPath (Join-Path $runner '.runner') -Value '{}' -foreach ($file in @('windows-runner-task.ps1','run-windows-runner-task.ps1')) { Copy-Item -LiteralPath (Join-Path $source $file) -Destination $root } +foreach ($file in @('windows-runner-task.ps1','run-windows-runner-task.ps1','windows-runner-launcher.cs')) { Copy-Item -LiteralPath (Join-Path $source $file) -Destination $root } $manager = Join-Path $root 'windows-runner-task.ps1' @' param([string]$RunnerRoot, [string]$ConfigRoot, [switch]$CheckOnly) if ($CheckOnly) { exit 0 } +Add-Type -TypeDefinition 'using System; using System.Runtime.InteropServices; public static class ConsoleProbe { [DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow(); }' +Add-Content -LiteralPath (Join-Path $RunnerRoot 'consoles.txt') -Value ([ConsoleProbe]::GetConsoleWindow().ToInt64()) Add-Content -LiteralPath (Join-Path $RunnerRoot 'starts.txt') -Value $PID if (-not (Test-Path -LiteralPath (Join-Path $RunnerRoot 'failed-once'))) { Set-Content -LiteralPath (Join-Path $RunnerRoot 'failed-once') -Value 'yes' @@ -25,7 +27,7 @@ function Invoke-Manager([string]$Operation, [switch]$ExpectFailure) { & (Join-Path $PSHOME 'pwsh.exe') -NoProfile -File $manager -Action $Operation -RunnerRoot $runner @installOptions if (($LASTEXITCODE -eq 0) -eq [bool]$ExpectFailure) { throw "Unexpected exit $LASTEXITCODE for $Operation" } } -function Wait-Condition([scriptblock]$Condition, [string]$Description, [int]$Seconds = 30) { +function Wait-Condition([scriptblock]$Condition, [string]$Description, [int]$Seconds = 60) { $deadline = [DateTime]::UtcNow.AddSeconds($Seconds) do { if (& $Condition) { return } @@ -42,11 +44,37 @@ try { $installed = $true $config = Get-Content -LiteralPath (Join-Path $runner 'dsh-task/config.json') -Raw | ConvertFrom-Json $task = Get-ScheduledTask -TaskName $config.TaskName -TaskPath '\' + $launcher = Join-Path $runner 'dsh-task/launcher.exe' + if ($task.Actions.Execute -ne $launcher) { throw 'Task must start the windowless launcher.' } + $image = [IO.File]::ReadAllBytes($launcher) + $peOffset = [BitConverter]::ToInt32($image, 0x3c) + if ([BitConverter]::ToUInt16($image, $peOffset + 24 + 68) -ne 2) { throw 'Launcher must use the Windows GUI subsystem.' } if ($task.State -ne 'Disabled' -or $task.Settings.ExecutionTimeLimit -ne 'PT0S' -or $task.Settings.MultipleInstances -ne 'IgnoreNew') { throw 'Unexpected task scheduling settings.' } Invoke-Manager install -ExpectFailure + $runScript = Join-Path $runner 'dsh-task/run.ps1' + $originalRun = Get-Content -LiteralPath $runScript -Raw + Set-Content -LiteralPath (Join-Path $runner 'dsh-task/pipe-writer.ps1') -Value 'Start-Sleep -Seconds 240' + $inheritedWriter = @' +if (-not (Test-Path -LiteralPath (Join-Path $PSScriptRoot 'inherited-writer-started'))) { + $info = [Diagnostics.ProcessStartInfo]::new((Join-Path $PSHOME 'pwsh.exe')) + $info.UseShellExecute = $false + $info.CreateNoWindow = $true + foreach ($arg in @('-NoProfile', '-NonInteractive', '-File', (Join-Path $PSScriptRoot 'pipe-writer.ps1'))) { $info.ArgumentList.Add($arg) } + $writer = [Diagnostics.Process]::Start($info) + Set-Content -LiteralPath (Join-Path $PSScriptRoot 'inherited-writer-started') -Value $writer.Id + $writer.Dispose() +} +'@ + Set-Content -LiteralPath $runScript -Value ($inheritedWriter + "`n" + $originalRun) Invoke-Manager start $starts = Join-Path $runner 'starts.txt' - Wait-Condition { (Test-Path -LiteralPath $starts) -and @(Get-Content -LiteralPath $starts).Count -ge 2 } 'automatic retry after unexpected clean exit' 110 + Wait-Condition { (Get-ScheduledTaskInfo -TaskName $taskName -TaskPath '\').LastTaskResult -eq 5 } 'inherited writer returns drain timeout' + if ((Get-ScheduledTask -TaskName $taskName -TaskPath '\').State -eq 'Disabled') { throw 'Drain timeout disabled automatic recovery.' } + # The one-minute scheduler trigger and cold PowerShell/CIM startup share the busy Windows host. + Wait-Condition { (Test-Path -LiteralPath $starts) -and @(Get-Content -LiteralPath $starts).Count -ge 2 } 'automatic retry after unexpected clean exit' 180 + if ([int](Get-Content -LiteralPath (Join-Path $runner 'dsh-task/attempts.txt') -Raw) -ne 2) { throw 'Drain recovery did not consume the normal restart budget.' } + $consoles = @(Get-Content -LiteralPath (Join-Path $runner 'consoles.txt')) + if ($consoles.Count -ne 2 -or @($consoles | Where-Object { $_ -ne '0' }).Count) { throw 'Initial or recovered runner allocated a console.' } $childId = [int]@(Get-Content -LiteralPath $starts)[-1] $child = Get-Process -Id $childId Invoke-Manager start @@ -79,11 +107,34 @@ try { Start-ScheduledTask -TaskName $config.TaskName -TaskPath '\' Wait-Condition { (Get-ScheduledTask -TaskName $config.TaskName -TaskPath '\').State -eq 'Disabled' } 'invalid restart budget disables task' if (@(Get-Content -LiteralPath $starts).Count -ne 3) { throw 'Invalid budget launched another runner.' } + $savedAction = (Get-ScheduledTask -TaskName $taskName -TaskPath '\').Actions + $missingAction = New-ScheduledTaskAction -Execute $launcher -Argument ('"' + (Join-Path $root 'missing.exe') + '"') -WorkingDirectory $runner + $null = Set-ScheduledTask -TaskName $taskName -TaskPath '\' -Action $missingAction + Set-Content -LiteralPath (Join-Path $runner 'dsh-task/attempts.txt') -Value '0' + $null = Enable-ScheduledTask -TaskName $taskName -TaskPath '\' + Start-ScheduledTask -TaskName $taskName -TaskPath '\' + Wait-Condition { (Get-ScheduledTask -TaskName $taskName -TaskPath '\').State -eq 'Disabled' } 'launcher startup failure disables periodic starts' + Wait-Condition { (Get-ScheduledTaskInfo -TaskName $taskName -TaskPath '\').LastTaskResult -eq 2 } 'launcher startup failure result' + $null = Set-ScheduledTask -TaskName $taskName -TaskPath '\' -Action $savedAction + $taskDirectory = Join-Path $runner 'dsh-task' + $originalAcl = Get-Acl -LiteralPath $taskDirectory + try { + $blockedAcl = Get-Acl -LiteralPath $taskDirectory + $sid = [Security.Principal.WindowsIdentity]::GetCurrent().User + $blockedAcl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($sid, 'WriteData, AppendData', 'ContainerInherit, ObjectInherit', 'None', 'Deny')) + Set-Acl -LiteralPath $taskDirectory -AclObject $blockedAcl + $null = Enable-ScheduledTask -TaskName $taskName -TaskPath '\' + Start-ScheduledTask -TaskName $taskName -TaskPath '\' + Wait-Condition { (Get-ScheduledTask -TaskName $taskName -TaskPath '\').State -eq 'Disabled' } 'unwritable task directory disables periodic starts' + Wait-Condition { (Get-ScheduledTaskInfo -TaskName $taskName -TaskPath '\').LastTaskResult -eq 3 } 'unwritable diagnostic result' + } finally { Set-Acl -LiteralPath $taskDirectory -AclObject $originalAcl } + if (@(Get-Content -LiteralPath $starts).Count -ne 3) { throw 'Launcher failure started another runner.' } + if ([int](Get-Content -LiteralPath (Join-Path $taskDirectory 'attempts.txt') -Raw) -ne 0) { throw 'Startup failure unexpectedly entered run.ps1.' } Invoke-Manager uninstall $installed = $false Invoke-Manager status -ExpectFailure if (-not (Test-Path -LiteralPath (Join-Path $runner '.runner'))) { throw 'Registration removed.' } - Write-Output "PASS: install, duplicate refusal, automatic retry, start, status, stop, child teardown, retry exhaustion, uninstall. Evidence: $root" + Write-Output "PASS: windowless initial launch and recovery, install, duplicate refusal, automatic retry, start, status, stop, child teardown, retry exhaustion, startup failure containment, unwritable diagnostics, uninstall. Evidence: $root" } catch { $primaryFailure = $_; throw } finally { $cleanupErrors = @() if ($installed) { @@ -91,8 +142,8 @@ try { try { Stop-ScheduledTask -TaskName $taskName -TaskPath '\' } catch { $cleanupErrors += $_ } try { Unregister-ScheduledTask -TaskName $taskName -TaskPath '\' -Confirm:$false } catch { $cleanupErrors += $_ } } - foreach ($process in @(Get-CimInstance Win32_Process -Filter "Name = 'pwsh.exe'")) { - if ($process.CommandLine -and $process.CommandLine.Contains($root)) { + foreach ($process in @(Get-CimInstance Win32_Process -Filter "Name = 'pwsh.exe' OR Name = 'launcher.exe'")) { + if (($process.Name -eq 'pwsh.exe' -and $process.CommandLine -and $process.CommandLine.Contains($root)) -or ($process.Name -eq 'launcher.exe' -and $process.ExecutablePath -and $process.ExecutablePath.StartsWith($root + '\', [StringComparison]::OrdinalIgnoreCase))) { $owned = Get-Process -Id $process.ProcessId -ErrorAction SilentlyContinue if ($owned) { try { diff --git a/apps/desktop/windows-signing/README.i18n.yaml b/apps/desktop/windows-signing/README.i18n.yaml index 6d77ed774c..bc973d1ee7 100644 --- a/apps/desktop/windows-signing/README.i18n.yaml +++ b/apps/desktop/windows-signing/README.i18n.yaml @@ -21,8 +21,8 @@ en: bb889e9514678d1c zh: 48cbec1e907d6a2b /windows-signing-and-runner-operations/machine-setup-and-runner-startup/supervised-startup: - en: d32f9f8ce9f5b861 - zh: 80d9078a7763f85d + en: d8e2ed7bde76ed7e + zh: 475917ecbd158b48 /windows-signing-and-runner-operations/signing-configuration: en: 4b5548818375eb9a zh: 8c975adbb1dca9c8 diff --git a/apps/desktop/windows-signing/README.md b/apps/desktop/windows-signing/README.md index 0109bbbaed..2ecd4b9dd5 100644 --- a/apps/desktop/windows-signing/README.md +++ b/apps/desktop/windows-signing/README.md @@ -36,13 +36,15 @@ Run `pwsh -NoProfile -File apps/desktop/scripts/setup-windows-packaging.ps1 -Roo ### Supervised startup +For the complete registration, label, routing and approval sequence, start with [CI publication setup](../publishing/ci/README.md#setup-sequence). The task installer requires the Windows .NET Framework 4.5+ compiler at `%SystemRoot%/Microsoft.NET/Framework64/v4.0.30319/csc.exe`. It compiles a GUI executable into `runner/dsh-task/launcher.exe`; this executable starts PowerShell with no console and waits for it to exit. The output and errors of `run.ps1` itself go to uniquely named `launcher-*.out.log` and `launcher-*.err.log`; filenames include UTC time, PID and a random identifier. Launcher failures disable periodic starts, including failures before the retry budget can be read. Task result `2` indicates launcher failure; `3` means its error log could not be written; `4` means disabling the task also failed and an operator must disable it in Task Scheduler. After the direct child exits, output draining is limited to five seconds. A timeout returns `5` with a `launcher-*.drain.log` diagnostic: output may be incomplete, but periodic recovery continues within the existing restart budget. Descendant processes may outlive the exited runner; draining logs does not terminate them. The recorded stop target is the launcher process, so stop includes the launcher and its descendants. Normal runner output remains in `run-*.log`. + Run `pwsh -NoProfile -File apps/desktop/scripts/windows-runner-task.ps1 -Action install -RunnerRoot -ConfigRoot /packaging` as the runner's Windows user, using its installed PowerShell 7. The installer checks the environment and copies startup scripts into `runner/dsh-task`; registration, tools and secrets remain separate. It registers a disabled, limited-permission interactive Task Scheduler task without storing an account password. For `-Action start` or `-Action status`, pass only `-RunnerRoot`; configuration and retry options apply only to installation. The task starts after this user's desktop login, including after reboot, and stops on logout. It does not provide unattended boot or unlock a signing Token. -The task permits one instance and has no execution time limit. A repeating trigger checks every `-RestartMinutes` minutes (default `1`) and restarts an exited launcher, including an unexpected zero exit. The persisted budget permits the initial launch plus `-RestartCount` restarts (default `10`) between explicit starts. Every desktop login also consumes this budget; `status` shows the remaining launches. After the budget is exhausted, login alone cannot restart the runner. Starting an already running task does not reset the budget. Missing or invalid budget/configuration files disable automatic starts until repaired. GitHub's launcher owns runner updates; task recovery does not rerun a failed workflow or clear signing interlocks. After retries are exhausted, inspect `runner/dsh-task/run-*.log`, the runner's `_diag` logs and `status`, correct the cause, then run `start`. A running task alone does not prove connectivity: verify `Connected to GitHub` and `Listening for Jobs`, then verify assignment in Actions. Logs are retained for operator-managed cleanup. +The task permits one instance and has no execution time limit. A repeating trigger checks every `-RestartMinutes` minutes (default `1`) and restarts an exited launcher, including an unexpected zero exit. The persisted budget permits the initial launch plus `-RestartCount` restarts (default `10`) between explicit starts. Every desktop login also consumes this budget; `status` shows the remaining launches. After the budget is exhausted, login alone cannot restart the runner. Starting an already running task does not reset the budget. Missing or invalid budget/configuration files disable automatic starts until repaired. GitHub's launcher owns runner updates; task recovery does not rerun a failed workflow or clear signing interlocks. After retries are exhausted, inspect `runner/dsh-task/run-*.log`, the runner's `_diag` logs and `status`, correct the cause, then run `start`. A running task alone does not prove connectivity: verify `Connected to GitHub` and `Listening for Jobs`, then verify assignment in Actions. Both `run-*.log` and `launcher-*.log` are retained for operator-managed cleanup. -Before maintenance, pause new workflow dispatch and wait for the runner to become idle. `-Action stop` refuses an active worker for this runner, disables future starts and stops its task process tree; the local check cannot prevent a concurrent GitHub assignment. `-Action uninstall` requires a stopped, disabled task and retains registration, configuration and logs. To update installed scripts, stop, uninstall and install again. When migrating a manually launched runner, stop its existing launcher only after it is idle, then start the task; never run both launchers together. +Before maintenance, pause new workflow dispatch and wait for the runner to become idle. `-Action stop` refuses an active worker for this runner, disables future starts and stops its task process tree; the local check cannot prevent a concurrent GitHub assignment. `-Action uninstall` requires a stopped, disabled task and retains registration, configuration and logs. Pulling repository changes does not update the installed copy. To upgrade it, record the configuration directory and retry settings, then run `stop`, `uninstall`, `install` with those settings, and `start` from the updated checkout. Confirm listening and job assignment again. When migrating a manually launched runner, stop its existing launcher only after it is idle, then start the task; never run both launchers together. -Operators can run `pwsh -NoProfile -File apps/desktop/tests/windows-runner-task.ps1` on Windows to exercise installation, automatic recovery, duplicate-start prevention, stop, child-process teardown and uninstall against an isolated fake runner. This opt-in test creates a temporary task and retains diagnostic files; it does not contact GitHub or access signing hardware. Real-machine acceptance additionally checks an idle runner coming online, an Actions job being assigned, and login after an operator-approved reboot. Signed packaging and logout/reboot acceptance require separate operator verification. +Operators can run `pwsh -NoProfile -File apps/desktop/tests/windows-runner-task.ps1` on Windows to exercise installation, console-free initial launch and automatic recovery, duplicate-start prevention, stop, child-process teardown, containment of startup/logging failures and uninstall against an isolated fake runner. This opt-in test creates a temporary task and retains diagnostic files; it does not contact GitHub or access signing hardware. `pwsh -NoProfile -File apps/desktop/tests/windows-runner-launcher.ps1` checks the GUI executable, child console absence, exit status, diagnostic output and bounded draining with an inherited writer without Task Scheduler; Windows CI runs it. Real-machine acceptance additionally checks an idle runner coming online without a visible Terminal window, an Actions job being assigned, and login after an operator-approved reboot. Signed packaging and logout/reboot acceptance require separate operator verification. diff --git a/apps/desktop/windows-signing/README.zh.md b/apps/desktop/windows-signing/README.zh.md index 56ba17eef7..80471f487b 100644 --- a/apps/desktop/windows-signing/README.zh.md +++ b/apps/desktop/windows-signing/README.zh.md @@ -36,13 +36,15 @@ description: "配置和维护 Windows 打包 runner、签名凭据、EV Token ### 受监督启动 +完整的注册、标签、路由和审批配置顺序见 [CI 发布配置](../publishing/ci/README.zh.md#setup-sequence)。任务安装器需要 Windows .NET Framework 4.5+ 编译器,路径为 `%SystemRoot%/Microsoft.NET/Framework64/v4.0.30319/csc.exe`。它将 GUI 可执行文件编译到 `runner/dsh-task/launcher.exe`;该程序不分配控制台地启动 PowerShell,并等待其退出。`run.ps1` 自身的输出和错误分别写入名称唯一的 `launcher-*.out.log` 和 `launcher-*.err.log`,文件名包含 UTC 时间、PID 和随机标识。启动器失败时禁用周期启动,包括尚未读取重试预算时发生的失败。任务结果 `2` 表示启动器失败,`3` 表示无法写入错误日志,`4` 表示禁用任务也失败,需要操作者在计划任务中手动禁用。直接子进程退出后,最多等待五秒排空输出;超时返回 `5`,将诊断写入 `launcher-*.drain.log`:输出可能不完整,但周期恢复仍受原有重试预算约束。后代进程可能在 runner 退出后继续运行;日志排空不会终止它们。记录的停止目标是启动器进程,因此 stop 覆盖启动器及其后代。正常 runner 输出仍保存在 `run-*.log`。 + 以 runner 的 Windows 用户运行 `pwsh -NoProfile -File apps/desktop/scripts/windows-runner-task.ps1 -Action install -RunnerRoot -ConfigRoot /packaging`,使用该机器安装的 PowerShell 7。安装器检查环境,并将启动脚本复制到 `runner/dsh-task`;注册、工具和凭据仍单独管理。它注册一个初始禁用、权限受限的交互式计划任务,不保存账户密码。执行 `-Action start` 或 `-Action status` 时只传 `-RunnerRoot`;配置目录及重试选项仅适用于安装。任务在该用户登录桌面后启动,重启后也需要登录,注销时停止。它不提供无人值守开机,也不解锁签名 Token。 -任务只允许一个实例,且没有执行时限。周期触发器每隔 `-RestartMinutes` 分钟(默认 `1`)检查一次,并恢复已退出的启动器,包括意外的零退出码。持久预算允许首次启动及 `-RestartCount` 次重启(默认 `10`),直到再次显式启动。每次桌面登录也会消耗预算,`status` 显示剩余启动次数;预算耗尽后,仅登录不能重启 runner。对正在运行的任务执行 start 不会重置预算。预算或配置文件缺失、无效时,自动启动会禁用,需修复后恢复。GitHub 启动器负责 runner 更新;任务恢复不会重跑失败的 workflow,也不会清除签名互锁。重试耗尽后,检查 `runner/dsh-task/run-*.log`、runner 的 `_diag` 日志和 `status`,修复原因后运行 `start`。任务运行不等于连接成功:确认出现 `Connected to GitHub` 和 `Listening for Jobs`,再到 Actions 确认任务分配。日志保留,由操作者管理清理。 +任务只允许一个实例,且没有执行时限。周期触发器每隔 `-RestartMinutes` 分钟(默认 `1`)检查一次,并恢复已退出的启动器,包括意外的零退出码。持久预算允许首次启动及 `-RestartCount` 次重启(默认 `10`),直到再次显式启动。每次桌面登录也会消耗预算,`status` 显示剩余启动次数;预算耗尽后,仅登录不能重启 runner。对正在运行的任务执行 start 不会重置预算。预算或配置文件缺失、无效时,自动启动会禁用,需修复后恢复。GitHub 启动器负责 runner 更新;任务恢复不会重跑失败的 workflow,也不会清除签名互锁。重试耗尽后,检查 `runner/dsh-task/run-*.log`、runner 的 `_diag` 日志和 `status`,修复原因后运行 `start`。任务运行不等于连接成功:确认出现 `Connected to GitHub` 和 `Listening for Jobs`,再到 Actions 确认任务分配。`run-*.log` 和 `launcher-*.log` 均保留,由操作者管理清理。 -维护前暂停新 workflow 的触发,等待 runner 空闲。`-Action stop` 在该 runner 存在活跃 worker 时拒绝停止,否则禁用后续启动并停止任务进程树;本地检查无法阻止 GitHub 同时分配任务。`-Action uninstall` 要求任务已停止并禁用,保留注册、配置和日志。更新已安装脚本时,依次停止、卸载、重新安装。迁移手动启动的 runner 时,只在空闲后停止原启动器,再启动计划任务;不要同时运行两个启动器。 +维护前暂停新 workflow 的触发,等待 runner 空闲。`-Action stop` 在该 runner 存在活跃 worker 时拒绝停止,否则禁用后续启动并停止任务进程树;本地检查无法阻止 GitHub 同时分配任务。`-Action uninstall` 要求任务已停止并禁用,保留注册、配置和日志。拉取仓库更新不会更新已安装的副本。升级时先记录配置目录和重试设置,再从更新后的 checkout 依次执行 `stop`、`uninstall`、携带原设置的 `install` 和 `start`,最后重新确认监听与 job 分配。迁移手动启动的 runner 时,只在空闲后停止原启动器,再启动计划任务;不要同时运行两个启动器。 -操作者可在 Windows 上运行 `pwsh -NoProfile -File apps/desktop/tests/windows-runner-task.ps1`,使用隔离的假 runner 验证安装、自动恢复、防止重复启动、停止、子进程退出和卸载。此显式运行的测试创建临时任务并保留诊断文件,不连接 GitHub 或访问签名硬件。真实机器验收还需检查空闲 runner 上线、Actions job 分配,以及经操作者批准的重启后登录。签名打包和注销/重启验收需要操作者单独验证。 +操作者可在 Windows 上运行 `pwsh -NoProfile -File apps/desktop/tests/windows-runner-task.ps1`,使用隔离的假 runner 验证安装、无控制台的首次启动与自动恢复、防止重复启动、停止、子进程退出、启动/日志失败时的禁用处理和卸载。此显式运行的测试创建临时任务并保留诊断文件,不连接 GitHub 或访问签名硬件。`pwsh -NoProfile -File apps/desktop/tests/windows-runner-launcher.ps1` 无需计划任务,检查 GUI 可执行文件、子进程无控制台、退出状态、诊断输出以及存在继承管道写端时的有界排空;Windows CI 会执行该测试。真实机器验收还需检查空闲 runner 上线且不出现 Terminal 窗口、Actions job 分配,以及经操作者批准的重启后登录。签名打包和注销/重启验收需要操作者单独验证。 diff --git a/apps/web/tests/chat-flow-layout.e2e.ts b/apps/web/tests/chat-flow-layout.e2e.ts index d3d5e999ac..6b45355d2e 100644 --- a/apps/web/tests/chat-flow-layout.e2e.ts +++ b/apps/web/tests/chat-flow-layout.e2e.ts @@ -54,7 +54,15 @@ it.each(['turn-tail-actions', 'goal-multi-turn-actions', 'present'] as const)( const { top, bottom } = seat.getBoundingClientRect() return { top, bottom } }) - expect(answerBounds.top).toBe(await process.evaluate(element => element.getBoundingClientRect().bottom) + 16) + const processBottom = await process.evaluate(element => element.getBoundingClientRect().bottom) + // Hidden process rows separate the folded control from the first visible reply, so it keeps the 12px reply gap. + const lead = scenario === 'present' ? answer : page.getByText('Reading the workspace now.', { exact: true }) + const leadTop = await lead.evaluate((element) => { + const seat = element.closest('[data-chat-flow-kind="assistant-step"]') + if (seat === null) throw new Error('first visible reply has no Chat seat') + return seat.getBoundingClientRect().top + }) + expect(leadTop).toBe(processBottom + 12) if (scenario === 'present') { const geometry = await page.locator('[data-presented-files-row]').evaluate((grid) => { const root = grid.parentElement diff --git a/apps/web/tests/expected/desktop-locale/automatic.expected.md b/apps/web/tests/expected/desktop-locale/automatic.expected.md index b24ebca8d2..84a652e494 100644 --- a/apps/web/tests/expected/desktop-locale/automatic.expected.md +++ b/apps/web/tests/expected/desktop-locale/automatic.expected.md @@ -24,6 +24,8 @@ - button "标准" - text: 工作步骤收起时机 选择何时自动收起工作步骤 - button "回答结束后" + - text: 收起后显示的回复 工作步骤收起后,保留哪些助手回复 + - button "全部回复" - text: 显示代码工作视图 开启后,显示轨迹、本轮代码差异,可选择完整的 Agent 预设切换 - switch "显示代码工作视图" [checked] - text: 快捷键 diff --git a/apps/web/tests/expected/desktop-locale/selected.expected.md b/apps/web/tests/expected/desktop-locale/selected.expected.md index 801a3d9a5d..865390f104 100644 --- a/apps/web/tests/expected/desktop-locale/selected.expected.md +++ b/apps/web/tests/expected/desktop-locale/selected.expected.md @@ -24,6 +24,8 @@ - button "Standard" - text: When to Collapse Work Details Choose when to automatically collapse work details - button "On completion" + - text: Replies After Collapse Choose which assistant replies stay visible when work details collapse + - button "All replies" - text: Show coding view Shows trajectory, code diffs, and all Agent presets - switch "Show coding view" [checked] - text: Keyboard shortcuts diff --git a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md index 13cd42f9d7..e36458f98a 100644 --- a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md @@ -24,6 +24,8 @@ - button "Detailed" - text: When to Collapse Work Details Choose when to automatically collapse work details - button "On completion" + - text: Replies After Collapse Choose which assistant replies stay visible when work details collapse + - button "All replies" - text: Show coding view Shows trajectory, code diffs, and all Agent presets - switch "Show coding view" - text: Keyboard shortcuts diff --git a/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md b/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md index 13cd42f9d7..e36458f98a 100644 --- a/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md @@ -24,6 +24,8 @@ - button "Detailed" - text: When to Collapse Work Details Choose when to automatically collapse work details - button "On completion" + - text: Replies After Collapse Choose which assistant replies stay visible when work details collapse + - button "All replies" - text: Show coding view Shows trajectory, code diffs, and all Agent presets - switch "Show coding view" - text: Keyboard shortcuts diff --git a/apps/web/tests/expected/settings-chrome/dialog.expected.md b/apps/web/tests/expected/settings-chrome/dialog.expected.md index df60e07cde..9f9c6ee85e 100644 --- a/apps/web/tests/expected/settings-chrome/dialog.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog.expected.md @@ -24,6 +24,8 @@ - button "详细" - text: 工作步骤收起时机 选择何时自动收起工作步骤 - button "回答结束后" + - text: 收起后显示的回复 工作步骤收起后,保留哪些助手回复 + - button "全部回复" - text: 显示代码工作视图 开启后,显示轨迹、本轮代码差异,可选择完整的 Agent 预设切换 - switch "显示代码工作视图" - text: 快捷键 diff --git a/apps/web/tests/fold-blank-matrix.e2e.ts b/apps/web/tests/fold-blank-matrix.e2e.ts index 3422c15238..25b4e2d874 100644 --- a/apps/web/tests/fold-blank-matrix.e2e.ts +++ b/apps/web/tests/fold-blank-matrix.e2e.ts @@ -42,7 +42,9 @@ const STEER_STEPS = 3 const BLANK_LIMIT = 120 /** * Frames whose layout gap may exceed {@link BLANK_LIMIT}. The gap counts rows by their boxes, so a - * closing row that has faded but not finished shrinking still covers its space. + * closing row that has faded but not finished shrinking still covers its space. A band whose rows on + * both sides have faded to full transparency shows the same background as those rows and does not + * count; a band beside a visible row always counts. */ const TRANSIENT_FRAMES = 1 const EVIDENCE = fileURLToPath(new URL('../../../.artifacts/screenshots/fold-blank-matrix/', import.meta.url)) @@ -155,23 +157,37 @@ function installMeasure(): void { if (composer === null) throw new Error(`composer is absent: ${root}`) const composerTop = composer.getBoundingClientRect().top const spacer = host.querySelector('[data-chat-turn-spacer]')?.getBoundingClientRect().height ?? 0 + // Fold motion writes row opacity, so a row is transparent when it or a moving ancestor has opacity 0. + const visible = (element: HTMLElement): boolean => { + for (let box = element.closest('[data-chat-motion]'); box !== null && host.contains(box); + box = box.parentElement?.closest('[data-chat-motion]') ?? null) { + if (Number.parseFloat(getComputedStyle(box).opacity) === 0) return false + } + return true + } const intervals = [...host.querySelectorAll('[data-chat-flow-key], [data-submission-echo], [data-pending-steering], [data-streaming], [data-chat-running]')] .filter(element => element.closest('[hidden]') === null) .map((element) => { const rect = element.getBoundingClientRect() - return [Math.max(rect.top, viewport.top), Math.min(rect.bottom, composerTop)] as const + return [Math.max(rect.top, viewport.top), Math.min(rect.bottom, composerTop), visible(element)] as const }) .filter(([top, bottom]) => bottom - top > 0.5) .sort((left, right) => left[0] - right[0]) let cursor = viewport.top + let cursorVisible = true let maxGap = 0 let gapAt = 0 - for (const [top, bottom] of intervals) { - if (top - cursor > maxGap) { + for (const [top, bottom, rowVisible] of intervals) { + if (top - cursor > maxGap && (cursorVisible || rowVisible)) { maxGap = top - cursor gapAt = cursor - viewport.top } - cursor = Math.max(cursor, bottom) + if (bottom > cursor) { + cursor = bottom + cursorVisible = rowVisible + } else if (bottom === cursor && rowVisible) { + cursorVisible = true + } } if (composerTop - cursor > maxGap) { maxGap = composerTop - cursor @@ -873,3 +889,27 @@ describe.skipIf(MODE === 'record')('web e2e: blank area after next-message foldi }) export {} + +describe('web e2e: fold blank measurement', () => { + it('skips a band between fully transparent rows and counts a band beside a visible row', async () => { + const browser = await chromium.launch() + try { + const page = await browser.newPage() + await page.setContent(`
+
+
+
+
+
+
`) + await page.evaluate(installMeasure) + const transparent = await page.evaluate(() => window.foldMeasure!('[data-measure-root]')) + expect([transparent.maxGap, transparent.gapAt]).toEqual([150, 500]) + await page.locator('[data-chat-flow-key="c"]').evaluate((element) => { element.style.opacity = '1' }) + const visible = await page.evaluate(() => window.foldMeasure!('[data-measure-root]')) + expect([visible.maxGap, visible.gapAt]).toEqual([300, 150]) + } finally { + await browser.close() + } + }) +}) diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 76c2bad173..d8b767960c 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -381,7 +381,8 @@ describe('web e2e: seeded history renders through cold resume', () => { const processBottom = await process.evaluate(element => element.getBoundingClientRect().bottom) const answerTop = await page.getByText('DONE', { exact: true }).evaluate(element => element.getBoundingClientRect().top) - expect(answerTop).toBe(processBottom + 16) + // Hidden process rows separate the folded control from the answer, so it keeps the 12px reply gap. + expect(answerTop).toBe(processBottom + 12) expect(await page.getByText('Context compacted', { exact: true }).count()).toBe(0) // Tool cards render from logged tool/call + tool/result alone (views are // host-recomputed per page; the generic card is the documented default). diff --git a/apps/web/tests/turn-tail-actions.e2e.ts b/apps/web/tests/turn-tail-actions.e2e.ts index 0f3128d92a..4b422197b5 100644 --- a/apps/web/tests/turn-tail-actions.e2e.ts +++ b/apps/web/tests/turn-tail-actions.e2e.ts @@ -249,9 +249,10 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => { expect(await process.evaluate(element => getComputedStyle(element).borderBottomWidth)).toBe('1px') const processBottom = await process.evaluate(element => element.closest('[data-chat-flow-kind="turn-process"]')?.getBoundingClientRect().bottom) - const answerTop = await page.getByText('DONE', { exact: true }).evaluate(element => + // The hidden reasoning row separates the folded control from the intermediate reply, so it keeps the 12px reply gap. + const leadTop = await page.getByText('Reading the workspace now.', { exact: true }).evaluate(element => element.closest('[data-chat-flow-kind="assistant-step"]')?.getBoundingClientRect().top) - expect(answerTop).toBe((processBottom ?? 0) + 16) + expect(leadTop).toBe((processBottom ?? 0) + 12) await process.focus() const completed = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd) await compareOrRefreshGolden(COMPLETED_EXPECTED, completed, MODE) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f5f4a22fbf..bc4eec2935 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2914,7 +2914,7 @@ export type JsonlCompression = 'zstd' | 'none' ## `@deepseek-ai/dsh-session-projection-cache` - `inject`: `storageDomain` · `sessionProjections` · `sessions` -- `source`: [`packages/session/session-projection-cache/src/index.ts:75`](../packages/session/session-projection-cache/src/index.ts) +- `source`: [`packages/session/session-projection-cache/src/index.ts:76`](../packages/session/session-projection-cache/src/index.ts) ```ts config-catalog /** diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 022ea67eb1..88327a30f4 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2916,7 +2916,7 @@ export type JsonlCompression = 'zstd' | 'none' ## `@deepseek-ai/dsh-session-projection-cache` - `inject`: `storageDomain` · `sessionProjections` · `sessions` -- `source`: [`packages/session/session-projection-cache/src/index.ts:75`](../packages/session/session-projection-cache/src/index.ts) +- `source`: [`packages/session/session-projection-cache/src/index.ts:76`](../packages/session/session-projection-cache/src/index.ts) ```ts config-catalog /** diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index fe621249d8..2cf8850c16 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/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:629`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:609`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:636`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:615`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:622`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:634`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:614`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:641`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:620`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:627`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:54`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../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`) | - | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index ba3d8b06f8..c15b9774bf 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -24,11 +24,11 @@ | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:629`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:609`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:636`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:615`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:622`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:634`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:614`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:641`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:620`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:627`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:54`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../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`) | - | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index ff3ff20d19..624cf55583 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -141,8 +141,8 @@ en: 9edf4ef31e60e880 zh: 9edf4ef31e60e880 /session-persistence-event-catalog/events/model/model-selection-log-only: - en: 0d9e20c2357ae7ac - zh: 23401d59e97d6011 + en: 5332aa18683a642f + zh: 025aa9bd6c4e022f /session-persistence-event-catalog/events/permission: en: 19917bc5438e60af zh: 19917bc5438e60af @@ -240,8 +240,8 @@ en: 308d4bb4b6b96e35 zh: 308d4bb4b6b96e35 /session-persistence-event-catalog/events/todo/todo-write-log-only: - en: 371e378bc5dc27c3 - zh: 4e12a6d2ec1bd139 + en: e2526a9e54307a41 + zh: 9da1a17aaf6abe6c /session-persistence-event-catalog/events/tool: en: 28a24430609494b2 zh: 28a24430609494b2 @@ -1146,8 +1146,8 @@ en: 3b677f021d7dd4d3 zh: 2a875742b22879a2 /session-persistence-event-catalog/resolved-persistence-types/modelselection: - en: 6d66ffc1b393f2f2 - zh: ef6cb30b71f287e7 + en: f75d48e8c151b06d + zh: c0f0dc5f69465285 /session-persistence-event-catalog/resolved-persistence-types/oneshotscheduledispatchchange: en: a3300babe57b6b67 zh: 4aaf06a149b399c3 @@ -1266,8 +1266,8 @@ en: c5fcd2cdd37ea387 zh: 9c678fbdea0bf25c /session-persistence-event-catalog/resolved-persistence-types/todoitem: - en: 06a9fe36dd3ff120 - zh: bf8f6fdeb666c1df + en: ee24f9407df530ac + zh: 310bedf3ac730d77 /session-persistence-event-catalog/resolved-persistence-types/todoitem~2: en: 8bf023246cbd0887 zh: dbf7113574c1bf59 @@ -1347,8 +1347,8 @@ en: 2786fef560851bb4 zh: 5d787475356b2606 /session-persistence-event-catalog/resolved-persistence-types/string: - en: 009c7ecb8c7d3bb5 - zh: 7f56d672f376b22f + en: 596f0a10b5a493b9 + zh: 9a2dd1fbe7c29141 /session-persistence-event-catalog/resolved-persistence-types/string~2: en: 0089620bf4638263 zh: 1922bef1713094bb @@ -1833,8 +1833,8 @@ en: 809a23c25ded8497 zh: e9cd23e281f8d31f /session-persistence-event-catalog/resolved-persistence-types/kind-user: - en: 9b6318ab4d7eba12 - zh: f475439ef40b2681 + en: a58f339332877b15 + zh: b7406af7d8197b64 /session-persistence-event-catalog/resolved-persistence-types/kind-user-approval: en: a52d2dfbaf844127 zh: 4c5306ca40f20873 @@ -1914,8 +1914,8 @@ en: a2fcd46e54b14120 zh: d9a6e0e50bbff9ad /session-persistence-event-catalog/resolved-persistence-types/todos: - en: 06e6c1837e2b5d50 - zh: 37cebfe3e41294ec + en: 8ab873d30e062fd5 + zh: a138832c6e299ebb /session-persistence-event-catalog/resolved-persistence-types/turn: en: c58b89cec274f956 zh: 056a29ed1ada9690 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 777d356058..3fcc549cde 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -700,7 +700,7 @@ Source: [`packages/llm/llm-retry/src/types.ts:11`](../packages/llm/llm-retry/src 'model/selection': ModelSelection ``` -Source: [`packages/api/session-controller/src/types.ts:41`](../packages/api/session-controller/src/types.ts) +Source: [`packages/api/session-controller/src/types.ts:45`](../packages/api/session-controller/src/types.ts) ### `permission/*` @@ -1072,7 +1072,7 @@ Source: [`packages/experimental/agent-team/src/types.ts:232`](../packages/experi Types: [TodoItem](subsystems/todo.md) -Source: [`packages/todo/tool-todo/src/types.ts:31`](../packages/todo/tool-todo/src/types.ts) +Source: [`packages/todo/tool-todo/src/types.ts:33`](../packages/todo/tool-todo/src/types.ts) ### `tool/*` @@ -4337,7 +4337,7 @@ Sources: [`packages/llm/llm/src/message.ts:24`](../packages/llm/llm/src/message. SHA-256: `f09e5e7acf5ff5ccd193ff2eeec015fa2ff7c29d98bc465f8c3302058d47e29b` -Sources: [`packages/api/session-controller/src/types.ts:101`](../packages/api/session-controller/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:106`](../packages/api/session-controller/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -5076,7 +5076,7 @@ Sources: [`packages/llm/llm/src/types.ts:62`](../packages/llm/llm/src/types.ts) SHA-256: `a99f76fd149051c363f960bfeeb5b9509587ecaf7591a42797e4bfe810d8987a` -Sources: [`packages/todo/tool-todo/src/types.ts:21`](../packages/todo/tool-todo/src/types.ts) +Sources: [`packages/todo/tool-todo/src/types.ts:23`](../packages/todo/tool-todo/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -5583,7 +5583,7 @@ Array of `number`. SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -Sources: [`packages/api/session-controller/src/types.ts:409`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:414`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -7554,7 +7554,7 @@ Sources: [`packages/core/tools/src/index.ts:35`](../packages/core/tools/src/inde SHA-256: `6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def` -Sources: [`packages/api/session-controller/src/types.ts:418`](../packages/api/session-controller/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:423`](../packages/api/session-controller/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7909,7 +7909,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:232`](../packages/exper SHA-256: `79ddc907312f97cf9553d4841e8b3fc937097bd8d97900a790efad3c05255e99` -Sources: [`packages/todo/tool-todo/src/types.ts:31`](../packages/todo/tool-todo/src/types.ts) +Sources: [`packages/todo/tool-todo/src/types.ts:33`](../packages/todo/tool-todo/src/types.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 43d12bdb49..9e4f34dbe0 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -702,7 +702,7 @@ export type SessionEvent = { 'model/selection': ModelSelection ``` -来源:[`packages/api/session-controller/src/types.ts:41`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:45`](../packages/api/session-controller/src/types.ts) ### `permission/*` @@ -1074,7 +1074,7 @@ export type SessionEvent = { 类型:[TodoItem](subsystems/todo.zh.md) -来源:[`packages/todo/tool-todo/src/types.ts:31`](../packages/todo/tool-todo/src/types.ts) +来源:[`packages/todo/tool-todo/src/types.ts:33`](../packages/todo/tool-todo/src/types.ts) ### `tool/*` @@ -4339,7 +4339,7 @@ SHA-256: `719c51f67074f97034a6c94297fe1f4b5d3c8d392c57586dab732aa5398c7a91` SHA-256: `f09e5e7acf5ff5ccd193ff2eeec015fa2ff7c29d98bc465f8c3302058d47e29b` -来源:[`packages/api/session-controller/src/types.ts:101`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:106`](../packages/api/session-controller/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -5078,7 +5078,7 @@ SHA-256: `4e887768586528565381dadbddee6cef555874148089f4579e0b4b1ad096fc9c` SHA-256: `a99f76fd149051c363f960bfeeb5b9509587ecaf7591a42797e4bfe810d8987a` -来源:[`packages/todo/tool-todo/src/types.ts:21`](../packages/todo/tool-todo/src/types.ts) +来源:[`packages/todo/tool-todo/src/types.ts:23`](../packages/todo/tool-todo/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -5585,7 +5585,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78` SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -来源:[`packages/api/session-controller/src/types.ts:409`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:414`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -7556,7 +7556,7 @@ SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543` SHA-256: `6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def` -来源:[`packages/api/session-controller/src/types.ts:418`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:423`](../packages/api/session-controller/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7911,7 +7911,7 @@ SHA-256: `a40d12070f6f4a124f32fb3cc86e7857554702e32f0eda3080a25c4b9ac9b18c` SHA-256: `79ddc907312f97cf9553d4841e8b3fc937097bd8d97900a790efad3c05255e99` -来源:[`packages/todo/tool-todo/src/types.ts:31`](../packages/todo/tool-todo/src/types.ts) +来源:[`packages/todo/tool-todo/src/types.ts:33`](../packages/todo/tool-todo/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 5974d8a7be..c4b83e27f8 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -23574,7 +23574,7 @@ "packages/workflow/workflow/src/types.ts#WorkflowRunId" ], "sources": [ - "packages/api/session-controller/src/types.ts:409", + "packages/api/session-controller/src/types.ts:414", "packages/attachment/attachment/src/brand.ts:6", "packages/compaction/compaction/src/brand.ts:4", "packages/core/session/src/types.ts:21", @@ -24096,7 +24096,7 @@ "digest": "6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def", "names": [], "sources": [ - "packages/api/session-controller/src/types.ts:418" + "packages/api/session-controller/src/types.ts:423" ] }, { @@ -24245,7 +24245,7 @@ "digest": "79ddc907312f97cf9553d4841e8b3fc937097bd8d97900a790efad3c05255e99", "names": [], "sources": [ - "packages/todo/tool-todo/src/types.ts:31" + "packages/todo/tool-todo/src/types.ts:33" ] }, { @@ -24965,7 +24965,7 @@ "packages/todo/tool-todo/src/types.ts#TodoItem" ], "sources": [ - "packages/todo/tool-todo/src/types.ts:21" + "packages/todo/tool-todo/src/types.ts:23" ] }, { @@ -25916,7 +25916,7 @@ "packages/api/session-controller/src/types.ts#ModelSelection" ], "sources": [ - "packages/api/session-controller/src/types.ts:101" + "packages/api/session-controller/src/types.ts:106" ] }, { diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml index 8d53d7ed36..25f948c599 100644 --- a/docs/subsystems/session-projection.i18n.yaml +++ b/docs/subsystems/session-projection.i18n.yaml @@ -11,6 +11,12 @@ /session-projections/the-snapshot-and-the-change-feed: en: a49a57c600c3cbd0 zh: c92f00b8a9d71bad +/session-projections/summary-reads: + en: d2bc9bcc775acfb7 + zh: b1d2ba74496305b8 +/session-projections/summary-reads/summary-vocabulary: + en: 6a93a4e1682fd6e6 + zh: 28729129c3d8a3b1 /session-projections/the-registry-ctx-sessionprojections: en: 50e79c1bc16abb63 zh: 200ecbf0e4ab19d2 diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index 8580b94c4d..69bda39657 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -46,7 +46,7 @@ interface ProjectionDefinition< /** Client view. Omit for host-only units. */ wire?: K extends keyof SessionProjectionMap ? { /** Validates the wire payload before it leaves the host. */ - viewSchema: ZodType + viewSchema: ZodType> /** * State → wire payload (the read-side projection). The live drive keeps * the two latest raw results and compares them with `Object.is`; an @@ -55,7 +55,15 @@ interface ProjectionDefinition< * @param state - the current state. * @returns the whole current value for this unit's key. */ - view(state: NoInfer): SessionProjectionMap[K] + view(state: NoInfer): SessionProjectionViewValue + /** + * Whether a Session summary carries this unit; omitted means `true`. + * Declare `false` on a key whose map entry carries the + * `SessionProjectionSummaryExcluded` mark: the runtime key set follows this + * field, while the client view type follows the mark. + * @default true + */ + summary?: boolean } : never /** * Persisted-cache invalidation version: bump whenever the serialized state fields or the @@ -101,6 +109,82 @@ type ProjectionChangeListener = ( `snapshot(session)` is fully synchronous: a carrier reads it in the same tick as its page slice, so `asOfSeq` covers both reads at one sequence number. It returns only client views, and every value passes its unit's `viewSchema` before return. `stateOf(session, key)` reads one live host state without computing unrelated views; callers must not mutate the borrowed reference. A state-reference change computes one cached raw view, and the change feed fires only when that result changes by `Object.is`; an object-valued view must preserve its reference to suppress publication across internal-only state changes. +## Summary reads + +A Session summary carries every registered client view except the keys whose `SessionProjectionMap` entry carries the `SessionProjectionSummaryExcluded` mark. The mark has no runtime representation: it decides the client view type, while `wire.summary` decides the runtime key set, with omission meaning `true`. A key whose entry carries the mark declares `wire.summary: false` so the two agree; the field is a plain boolean, so the type does not enforce it. + +`summaryKeys()` derives the key set from the live registrations and keeps returning the same instance until a registration or its disposal changes the key table. A summary consumer passes it to the existing key-taking faces — `cachedSnapshot(session, keys)` for cached cells and `viewCheckpoint(rows, keys)` for checkpoint rows, both keeping their watermark rules and both using a set without copying — so there is no summary-specific read face and no second definition of the set. + +A list row carries that summary projection, never the full projection: it promises the summary key set and nothing more. `snapshot`, `cachedSnapshot`, and `viewCheckpoint` remain complete when called without a key subset, which is how an opened Session, its follow baseline, and the control baseline keep every projected value; the `@` reference reads only its label keys through the same key-taking faces. + +### Summary vocabulary + +The declarations below state the summary rule. The mark, its stripping, the excluded-key test, and the summary value type are declared in the pure-type outlet; the key selection both read faces accept is declared in the service module. + +```ts type-equiv +/** + * One `SessionProjectionMap` entry excluded from Session summary payloads + * (the Session list row and any other summary read). The mark is type-only; + * `T` keeps its exact runtime value and wire representation. Wrap only the + * excluded contributor's own map entry, and place a nullable entry's `null` + * outside the wrapper (`SessionProjectionSummaryExcluded | null`) so + * the required symbol cannot absorb that member. + */ +type SessionProjectionSummaryExcluded = T & { + readonly [sessionProjectionSummaryExcluded]: true +} +``` + +```ts type-equiv +/** + * One map entry's client view value: the summary mark stripped. The + * conditional distributes over a nullable entry's union, so a `| null` member + * written outside the wrapper survives unchanged. + */ +type SessionProjectionViewValue = + T extends SessionProjectionSummaryExcluded ? Value : T +``` + +```ts type-equiv +/** Every client view value, keyed by projection key, with no summary mark. */ +type SessionProjectionViewValues = { + [K in keyof SessionProjectionMap]: SessionProjectionViewValue +} +``` + +```ts type-equiv +/** + * Keys a Session summary omits: map entries whose non-nullish value carries + * the summary mark. Nullish members are ignored, so a nullable entry is still + * recognized when its `null` lives outside the wrapper. + */ +type SessionProjectionSummaryExcludedKey = { + [K in keyof SessionProjectionMap]: + NonNullable extends SessionProjectionSummaryExcluded ? K : never +}[keyof SessionProjectionMap] +``` + +```ts type-equiv +/** + * The client values a Session summary carries: every client view value except + * {@link SessionProjectionSummaryExcludedKey}. A summary read returns these + * keys only; an opened Session's full snapshot keeps every view value. + */ +type SessionProjectionSummaryValues = + Omit +``` + +```ts type-equiv +/** + * One read's client-visible key selection: an explicit key list, or a derived + * set such as {@link SessionProjectionRegistry.summaryKeys}. A set is used as + * given; only a list is copied into one. + */ +type ProjectionKeySelection = + | readonly Extract[] + | ReadonlySet> +``` + ## The registry: `ctx.sessionProjections` `SessionProjectionRegistry` ([signatures](#ctxsessionprojections--sessionprojectionregistry)) owns the drive: one `session/event` subscription, eager `apply` over every registered unit, and per-session per-unit watermark cells. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect whose disposer rides the calling fiber: an unloaded domain plugin's key (with its cached cells) disappears from subsequent drives and snapshots, and clients read that as capability absence; a duplicate key with a different `stateVersion` throws, while same-version registrants share one unit and are counted. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected. @@ -139,7 +223,7 @@ The persisted projection cache service. Opens the `session_projcache` domain at * @returns the viewed block, or `undefined` when no usable row exists for * this lifecycle at the current Session format. */ -cachedSnapshot( meta: SessionHeader, keys?: readonly Extract[], ): ProjectionSnapshot | undefined +cachedSnapshot( meta: SessionHeader, keys?: ProjectionKeySelection, ): ProjectionSnapshot | undefined /** * Read only a predecessor checkpoint's title as a zero-I/O listing hint. @@ -252,7 +336,7 @@ stateOf( session: Session, key: K, ): * @param keys - optional client-visible outputs; state materialization remains complete. * @returns the snapshot; `values` is empty when no selected client-visible unit is registered. */ -snapshot( session: Session, keys?: readonly Extract[], ): ProjectionSnapshot +snapshot( session: Session, keys?: ProjectionKeySelection, ): ProjectionSnapshot /** * Read only already-materialized client-visible cells without folding history. @@ -262,7 +346,18 @@ snapshot( session: Session, keys?: readonly Extract[], ): ProjectionSnapshot | undefined +cachedSnapshot( session: Session, keys?: ProjectionKeySelection, ): ProjectionSnapshot | undefined + +/** + * The keys a Session summary carries: every registered client-visible key + * whose unit does not declare `summary: false`. The returned set is stable — + * the same instance on every call — until a registration or its disposal + * changes the key table, so a consumer may hold it across rows. Callers pass + * it to a key-taking read face such as `cachedSnapshot(session, summaryKeys())` + * or `viewCheckpoint(rows, summaryKeys())`; those faces use the set as-is. + * @returns the current summary key set; do not mutate it. + */ +summaryKeys(): ReadonlySet> /** * State-level checkpoint of every persisted unit for one session, read @@ -309,7 +404,7 @@ restoreFloor(checkpoint: ProjectionCheckpoint): SessionLogOffset | undefined * @param keys - optional wire keys to view. * @returns whole values per key with a usable row; empty when none. */ -viewCheckpoint( checkpoint: ProjectionCheckpoint, keys?: readonly Extract[], ): Partial +viewCheckpoint( checkpoint: ProjectionCheckpoint, keys?: ProjectionKeySelection, ): Partial /** * Cold read: fold every persisted unit over a stored log suffix, seeding diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index b98d348a36..8385dddb3b 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -46,7 +46,7 @@ interface ProjectionDefinition< /** Client view. Omit for host-only units. */ wire?: K extends keyof SessionProjectionMap ? { /** Validates the wire payload before it leaves the host. */ - viewSchema: ZodType + viewSchema: ZodType> /** * State → wire payload (the read-side projection). The live drive keeps * the two latest raw results and compares them with `Object.is`; an @@ -55,7 +55,15 @@ interface ProjectionDefinition< * @param state - the current state. * @returns the whole current value for this unit's key. */ - view(state: NoInfer): SessionProjectionMap[K] + view(state: NoInfer): SessionProjectionViewValue + /** + * Whether a Session summary carries this unit; omitted means `true`. + * Declare `false` on a key whose map entry carries the + * `SessionProjectionSummaryExcluded` mark: the runtime key set follows this + * field, while the client view type follows the mark. + * @default true + */ + summary?: boolean } : never /** * Persisted-cache invalidation version: bump whenever the serialized state fields or the @@ -101,6 +109,82 @@ type ProjectionChangeListener = ( `snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。它只返回客户端视图,并在返回前通过各单元的 `viewSchema` 校验。`stateOf(session, key)` 可在不计算无关视图的情况下读取一份实时 host 状态;调用方不得修改这一借用引用。state 引用变化时,注册表计算并缓存一次原始 view;只有该结果通过 `Object.is` 判定为变化时才触发变更流,对象 view 若要在仅内部 state 变化时抑制发布就必须保留引用。 +## 摘要读取 + +Session 摘要包含每个已注册客户端视图,但排除 `SessionProjectionMap` 条目带 `SessionProjectionSummaryExcluded` 标记的 key。标记没有运行时表示:它决定客户端视图类型,`wire.summary` 决定运行时键集合,省略即 `true`。map 条目带标记的 key 应声明 `wire.summary: false` 以保持一致;该字段是普通布尔值,类型层不做强制。 + +`summaryKeys()` 从实时注册派生该集合,并在注册或注销改变 key 表之前持续返回同一实例。摘要消费方把它传给既有的带 key 读取面 —— 缓存 cell 用 `cachedSnapshot(session, keys)`,checkpoint 行用 `viewCheckpoint(rows, keys)`,两者保持各自的水位规则且直接使用 Set、不做复制 —— 因此不存在摘要专用读取面,集合也只有一处定义。 + +列表行携带的是这个摘要投影,绝不是全量投影:它只承诺摘要键集合,不承诺更多。`snapshot`、`cachedSnapshot` 与 `viewCheckpoint` 在不传 key 子集时仍是完整的,打开的 Session、其 follow 基线与控制基线因此保留每个投影值;`@` 引用只通过同一组带 key 的读取面读取其标签 key。 + +### 摘要词汇 + +以下声明构成摘要规则。标记、其剥离、被排除键的判定与摘要值类型声明在纯类型出口;两个读取面共同接受的 key 选择声明在服务模块。 + +```ts type-equiv +/** + * One `SessionProjectionMap` entry excluded from Session summary payloads + * (the Session list row and any other summary read). The mark is type-only; + * `T` keeps its exact runtime value and wire representation. Wrap only the + * excluded contributor's own map entry, and place a nullable entry's `null` + * outside the wrapper (`SessionProjectionSummaryExcluded | null`) so + * the required symbol cannot absorb that member. + */ +type SessionProjectionSummaryExcluded = T & { + readonly [sessionProjectionSummaryExcluded]: true +} +``` + +```ts type-equiv +/** + * One map entry's client view value: the summary mark stripped. The + * conditional distributes over a nullable entry's union, so a `| null` member + * written outside the wrapper survives unchanged. + */ +type SessionProjectionViewValue = + T extends SessionProjectionSummaryExcluded ? Value : T +``` + +```ts type-equiv +/** Every client view value, keyed by projection key, with no summary mark. */ +type SessionProjectionViewValues = { + [K in keyof SessionProjectionMap]: SessionProjectionViewValue +} +``` + +```ts type-equiv +/** + * Keys a Session summary omits: map entries whose non-nullish value carries + * the summary mark. Nullish members are ignored, so a nullable entry is still + * recognized when its `null` lives outside the wrapper. + */ +type SessionProjectionSummaryExcludedKey = { + [K in keyof SessionProjectionMap]: + NonNullable extends SessionProjectionSummaryExcluded ? K : never +}[keyof SessionProjectionMap] +``` + +```ts type-equiv +/** + * The client values a Session summary carries: every client view value except + * {@link SessionProjectionSummaryExcludedKey}. A summary read returns these + * keys only; an opened Session's full snapshot keeps every view value. + */ +type SessionProjectionSummaryValues = + Omit +``` + +```ts type-equiv +/** + * One read's client-visible key selection: an explicit key list, or a derived + * set such as {@link SessionProjectionRegistry.summaryKeys}. A set is used as + * given; only a list is copied into one. + */ +type ProjectionKeySelection = + | readonly Extract[] + | ReadonlySet> +``` + ## 注册表:`ctx.sessionProjections` `SessionProjectionRegistry`([签名](#ctxsessionprojections--sessionprojectionregistry))拥有驱动权:一份 `session/event` 订阅、对每个已注册单元即时调用 `apply`,以及每会话每单元的水位线(watermark)cell。cell 惰性构建:在事件流过之后才注册的单元,或比注册表更早的会话,都在首次触达(事件或读取)时从 `init` 出发在内存日志上折叠。注册是一个 effect,其 disposer 随调用方 fiber 走:领域插件卸载后,其 key(连同缓存的 cell)从后续驱动与快照中消失,客户端将其读作能力缺失;key 以不同 `stateVersion` 重复时直接 throw,同版本注册方则共享一个单元并被计数。领域插件在 `ctx.inject(['sessionProjections'], …)` 下注册,因此不带注册表的 headless 组装完全不受影响。 @@ -139,7 +223,7 @@ The persisted projection cache service. Opens the `session_projcache` domain at * @returns the viewed block, or `undefined` when no usable row exists for * this lifecycle at the current Session format. */ -cachedSnapshot( meta: SessionHeader, keys?: readonly Extract[], ): ProjectionSnapshot | undefined +cachedSnapshot( meta: SessionHeader, keys?: ProjectionKeySelection, ): ProjectionSnapshot | undefined /** * Read only a predecessor checkpoint's title as a zero-I/O listing hint. @@ -252,7 +336,7 @@ stateOf( session: Session, key: K, ): * @param keys - optional client-visible outputs; state materialization remains complete. * @returns the snapshot; `values` is empty when no selected client-visible unit is registered. */ -snapshot( session: Session, keys?: readonly Extract[], ): ProjectionSnapshot +snapshot( session: Session, keys?: ProjectionKeySelection, ): ProjectionSnapshot /** * Read only already-materialized client-visible cells without folding history. @@ -262,7 +346,18 @@ snapshot( session: Session, keys?: readonly Extract[], ): ProjectionSnapshot | undefined +cachedSnapshot( session: Session, keys?: ProjectionKeySelection, ): ProjectionSnapshot | undefined + +/** + * The keys a Session summary carries: every registered client-visible key + * whose unit does not declare `summary: false`. The returned set is stable — + * the same instance on every call — until a registration or its disposal + * changes the key table, so a consumer may hold it across rows. Callers pass + * it to a key-taking read face such as `cachedSnapshot(session, summaryKeys())` + * or `viewCheckpoint(rows, summaryKeys())`; those faces use the set as-is. + * @returns the current summary key set; do not mutate it. + */ +summaryKeys(): ReadonlySet> /** * State-level checkpoint of every persisted unit for one session, read @@ -309,7 +404,7 @@ restoreFloor(checkpoint: ProjectionCheckpoint): SessionLogOffset | undefined * @param keys - optional wire keys to view. * @returns whole values per key with a usable row; empty when none. */ -viewCheckpoint( checkpoint: ProjectionCheckpoint, keys?: readonly Extract[], ): Partial +viewCheckpoint( checkpoint: ProjectionCheckpoint, keys?: ProjectionKeySelection, ): Partial /** * Cold read: fold every persisted unit over a stored log suffix, seeding diff --git a/packages/api/session-controller/src/client/sessions/lineage.ts b/packages/api/session-controller/src/client/sessions/lineage.ts index e74cd9dce5..298ff2183b 100644 --- a/packages/api/session-controller/src/client/sessions/lineage.ts +++ b/packages/api/session-controller/src/client/sessions/lineage.ts @@ -3,14 +3,19 @@ // Orphaned lineage degrades to root level; cycles fail soft and emit as roots. import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' +import type { SessionProjectionSummaryValues } from '@deepseek-ai/dsh-session-projection/types' import type { SessionSummary } from '../../types.ts' /** Host list summary enriched with the latest Session Controller title projection. */ export interface TitledSessionSummary extends SessionSummary { title?: string - /** Current host-computed projection values for list consumers. */ - projectionValues?: Readonly> + /** + * The row's summary projection values, never the full projection set. A + * connected Session's client store may hold more values from its + * control/follow baseline; those are outside the list contract and must not + * be read here. + */ + projectionValues?: Readonly> } /** One flattened session-list row with lineage depth. */ @@ -30,8 +35,11 @@ export interface SessionListEntry { /** Format classification supplied by the Host list. */ formatStatus?: SessionSummary['formatStatus'] cwd?: string - /** Current host-computed projection values for list consumers. */ - projectionValues?: Readonly> + /** + * The row's summary projection values, never the full projection set; see + * `TitledSessionSummary.projectionValues`. + */ + projectionValues?: Readonly> /** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */ depth: number } diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts index b8bec9be8a..f800a8c2be 100644 --- a/packages/api/session-controller/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -2,7 +2,7 @@ import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' import { SessionSeq, type SessionId, type SessionSeqCursor } from '@deepseek-ai/dsh-session/types' -import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' +import type { SessionProjectionMap, SessionProjectionViewValues } from '@deepseek-ai/dsh-session-projection/types' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import type { SessionControlBaseline, @@ -58,9 +58,14 @@ export interface SessionListSnapshot { } -/** Shared projection values and the lifecycle of their explicit baseline read. */ +/** + * Every projection value the store currently holds, with the summary mark + * stripped, and the lifecycle of its explicit baseline read. This is the full + * attached-Session set, not a list row's summary contract (see + * `SessionListEntry.projectionValues`). + */ export interface SessionProjectionSnapshot { - readonly values: Readonly> + readonly values: Readonly> readonly state: 'idle' | 'loading' | 'ready' | 'migration-required' | 'error' readonly error: RemoteFailure | null } diff --git a/packages/api/session-controller/src/client/sessions/service.ts b/packages/api/session-controller/src/client/sessions/service.ts index b992a34920..ee409f5fb6 100644 --- a/packages/api/session-controller/src/client/sessions/service.ts +++ b/packages/api/session-controller/src/client/sessions/service.ts @@ -5,7 +5,7 @@ import { SessionSeq, type SessionId } from '@deepseek-ai/dsh-session/types' import { workspaceTitleOf } from '@deepseek-ai/dsh-util-workspace-path' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import { SESSION_SEARCH_RESULT_LIMIT } from '../../types.ts' -import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' +import type { SessionProjectionSummaryValues } from '@deepseek-ai/dsh-session-projection/types' import { createSnapshotStore, notifySubscribers, type ObservableSnapshot, type SnapshotStore, } from '@deepseek-ai/dsh-client-store' @@ -49,8 +49,12 @@ export interface SessionSummary { */ blank: boolean updatedAt: number - /** Current host-computed projection values retained by the object layer. */ - projectionValues?: Readonly> + /** + * The row's summary projection values retained by the object layer, never the + * full projection set. A connected Session's store may hold more values from + * its control/follow baseline; those are outside the list contract. + */ + projectionValues?: Readonly> } /** Catalog metadata and local source counts; catalog membership owns no Client generation. */ diff --git a/packages/api/session-controller/src/list.ts b/packages/api/session-controller/src/list.ts index d7ef1ad43c..d14c4fbd17 100644 --- a/packages/api/session-controller/src/list.ts +++ b/packages/api/session-controller/src/list.ts @@ -16,7 +16,7 @@ import { SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS, } from './types.ts' import type { - SessionListMetadata, SessionProjectionHints, SessionProjectionValues, SessionSearchItem, + SessionListMetadata, SessionProjectionHints, SessionSearchItem, SessionSearchValue, SessionSummary, } from './types.ts' @@ -98,6 +98,7 @@ export class ApiSessionList { wire: { viewSchema: imageLimitsSchema, view: () => attachmentCtx.attachments.imageLimits, + summary: false, }, stateVersion: 1, }) @@ -293,16 +294,21 @@ export class ApiSessionList { session: Session | undefined, ): SessionProjectionHints | undefined { try { + // The registry derives the summary key set from its registrations and + // hands back one stable set until the key table changes; both faces below + // serve only those keys, so a list row cannot carry a unit that declares + // itself summary-excluded. + const keys = this.ctx.sessionProjections.summaryKeys() if (session !== undefined) { // The live registry computed the block for this Session: its watermark // shares the sequence space of the Session's baselines and frames. - return hintsOf('sequenced', this.ctx.sessionProjections.cachedSnapshot(session)) + return hintsOf('sequenced', this.ctx.sessionProjections.cachedSnapshot(session, keys)) } // A cold row reads the persisted cache by header alone; the cache serves // seeded and unseeded lifecycles alike because a listing never seeds a // fold. The watermark is the stored record's own. const cache = this.ctx.get('sessionProjectionCache') - return hintsOf('cached', cache?.cachedSnapshot(header) ?? cache?.cachedPredecessorTitle(header)) + return hintsOf('cached', cache?.cachedSnapshot(header, keys) ?? cache?.cachedPredecessorTitle(header)) } catch (error) { this.ctx.logger.warn( `api-session.list: projection column for "${header.id}" failed; serving the row without it: ${String(error)}`, @@ -313,7 +319,8 @@ export class ApiSessionList { } /** - * Wrap one projection block as Session-list hints of the named sequence space. + * Wrap one summary projection block as Session-list hints of the named sequence space. + * The block is the summary key set's values, not every value the source holds. * @param kind - which sequence space the block's watermark belongs to. * @param block - the block, or `undefined` when no source served one. * @returns the hints, or `undefined` when the block is absent or carries no value. @@ -323,9 +330,9 @@ function hintsOf( block: ProjectionSnapshot | undefined, ): SessionProjectionHints | undefined { if (block === undefined || Object.keys(block.values).length === 0) return undefined - // Listing hints contain every wire value the source currently holds but - // remain partial: missing cells and cache rows are never materialized here. - return { kind, asOfSeq: block.asOfSeq, values: block.values as SessionProjectionValues } + // Listing hints contain every summary wire value the source currently holds + // but remain partial: missing cells and cache rows are never materialized here. + return { kind, asOfSeq: block.asOfSeq, values: block.values } } function normalizeSearchQuery(query: string): string { diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index c0b2b9a573..dcde6460ab 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -8,7 +8,7 @@ import type { Branded } from '@deepseek-ai/dsh-brand' import type { LlmAttemptId, MessageId } from '@deepseek-ai/dsh-llm/brand' import type { TextBlock } from '@deepseek-ai/dsh-llm' import type { SessionId, SessionSeqCursor } from '@deepseek-ai/dsh-session/types' -import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' +import type { SessionProjectionMap, SessionProjectionSummaryExcluded, SessionProjectionSummaryValues } from '@deepseek-ai/dsh-session-projection/types' import type { Principal } from '@deepseek-ai/dsh-typert-protocol' import type { JsonValue } from '@deepseek-ai/dsh-util-values' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' @@ -25,8 +25,12 @@ declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionMap { /** Persisted facts used to summarize a Session without activating it. */ sessionListMetadata: SessionListMetadata - /** Image-intake limits enforced by the Session prompt endpoint. */ - imageLimits: ImageAttachmentLimits + /** + * Image-intake limits enforced by the Session prompt endpoint. Boot + * constant, excluded from Session summary payloads: every Session shares + * it, so a list row would repeat one host-wide fact. + */ + imageLimits: SessionProjectionSummaryExcluded /** Durable model selection already used and selected for the next request. */ modelSelection: ModelSelectionProjection } @@ -51,10 +55,11 @@ export interface SessionListMetadata { } /** - * Every available wire value a Session-list row carries as partial, possibly - * stale hints. `kind` and `asOfSeq` are independent facts: `kind` says which - * sequence space `asOfSeq` belongs to, and therefore how a client may merge - * the block; `asOfSeq` is the producer's watermark in that space. + * Every available summary wire value a Session-list row carries as partial, + * possibly stale hints. The block omits the keys whose unit declares + * `summary: false`. `kind` and `asOfSeq` are independent facts: `kind` says + * which sequence space `asOfSeq` belongs to, and therefore how a client may + * merge the block; `asOfSeq` is the producer's watermark in that space. */ export interface SessionProjectionHints { /** @@ -67,8 +72,8 @@ export interface SessionProjectionHints { readonly kind: 'cached' | 'sequenced' /** Watermark of the block in the sequence space named by `kind`. */ readonly asOfSeq: number - /** Provider-validated values present in the block; omitted keys remain unknown. */ - readonly values: SessionProjectionValues + /** Provider-validated summary values present in the block; omitted keys remain unknown. */ + readonly values: Partial } /** Complete projection values at an exact Session event cursor. */ diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts index bcf4a033be..a4f9367735 100644 --- a/packages/api/session-controller/tests/manager.client.spec.ts +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -17,6 +17,7 @@ import type {} from '@deepseek-ai/dsh-session-title/client' import { SessionManager } from '../src/client/sessions/manager.ts' import { ClientSessions } from '../src/client/sessions/service.ts' import type { SessionRemotes } from '../src/client/sessions/remotes.ts' +import type { SessionProjectionHints } from '../src/types.ts' import { entries, plainTurn } from './event-script.client.ts' import { FOLLOW, err, followScript, sessionWorld } from './remote/session.client.ts' @@ -1613,3 +1614,30 @@ describe('running facts without UI reminders', () => { expect(manager.getListSnapshot()).not.toHaveProperty('current') }) }) + +describe('Session summary projection replacement', () => { + it('drops a stale summary-excluded key when a reconnect refresh replaces the row block', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + // A row block from a verbose Host still carrying an excluded key; the wire + // type no longer admits it, so the fixture is parsed as annotated JSON. + const verboseBlock = JSON.parse( + '{"kind":"cached","asOfSeq":1,"values":{"title":"stale","turnOutline":[]}}', + ) as SessionProjectionHints + remote.session.list.mockResolvedValueOnce(ok({ items: [{ ...summary(S1), projections: verboseBlock }] })) + await manager.refreshList() + expect(manager.getListSnapshot().items[0]?.projectionValues).toHaveProperty('turnOutline') + + // A reconnect clears every per-Session value store before pulling again, so + // the refreshed summary block is the row's only source: the excluded key + // cannot survive the replacement. + remote.session.list.mockResolvedValueOnce(ok({ + items: [{ ...summary(S1), projections: { kind: 'cached', asOfSeq: 2, values: { title: 'fresh' } } }], + })) + manager.handleConnected() + await manager.refreshList() + const entry = manager.getListSnapshot().items[0] + expect(entry?.projectionValues).toEqual({ title: 'fresh' }) + expect(entry?.title).toBe('fresh') + }) +}) diff --git a/packages/api/session-controller/tests/session-projections.host.spec.ts b/packages/api/session-controller/tests/session-projections.host.spec.ts index ed4f4a6b0c..1c216cbcd5 100644 --- a/packages/api/session-controller/tests/session-projections.host.spec.ts +++ b/packages/api/session-controller/tests/session-projections.host.spec.ts @@ -532,7 +532,7 @@ describe('session.list projections column', () => { expect(row !== undefined && 'projections' in row).toBe(false) }) - it('serves every available cold projection hint from the cache with zero log loads', async () => { + it('serves the summary projection hints from the cache with zero log loads', async () => { const { ctx } = await harness(true) const coldId = SessionId('session-cold-listing') const load = () => { throw new Error('list must not load event logs') } @@ -541,10 +541,12 @@ describe('session.list projections column', () => { inspect: load, open: load, }) as never) + let seenKeys: unknown ctx.provide('sessionProjectionCache', { // The carrier hands the listed header through as the identity witness. - cachedSnapshot: (meta: { id: unknown; createdAt: number }) => - (meta.id === coldId && meta.createdAt === 5 + cachedSnapshot: (meta: { id: unknown; createdAt: number }, keys: unknown) => { + seenKeys = keys + return meta.id === coldId && meta.createdAt === 5 ? { asOfSeq: SessionSeq(7), values: { @@ -553,7 +555,8 @@ describe('session.list projections column', () => { title: 'Cached title', }, } - : undefined), + : undefined + }, } as never) const response = await remote(ctx).list(request({})) if (!response.ok) throw new Error('unreachable') @@ -568,6 +571,8 @@ describe('session.list projections column', () => { title: 'Cached title', }, }) + // The cold branch passes the registry's own summary set, not a fresh list. + expect(seenKeys).toBe(ctx.sessionProjections.summaryKeys()) }) it('keeps persisted host-only state out of a cold session.list response', async () => { diff --git a/packages/api/session-controller/tests/summary-list.host.spec.ts b/packages/api/session-controller/tests/summary-list.host.spec.ts new file mode 100644 index 0000000000..041d79e094 --- /dev/null +++ b/packages/api/session-controller/tests/summary-list.host.spec.ts @@ -0,0 +1,146 @@ +/** + * Session-list summary payloads: a `session.list` row carries every registered + * wire projection except the units that declare `summary: false` + * (`turnOutline`, `sessionStats`, `todos`, `imageLimits`), while an opened Session's + * `session/follow` opening snapshot keeps serving them. + */ + +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionFollowFrame } from '@deepseek-ai/dsh-api-session-controller/types' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import { + apply as sessionStatsApply, inject as sessionStatsInject, name as sessionStatsName, +} from '@deepseek-ai/dsh-session-stats' +import { + apply as turnOutlineApply, inject as turnOutlineInject, name as turnOutlineName, +} from '@deepseek-ai/dsh-session-turn-outline' +import { + apply as toolTodoApply, Config as toolTodoConfig, inject as toolTodoInject, name as toolTodoName, +} from '@deepseek-ai/dsh-tool-todo' +import { createSessionTestRemote } from './test-remote.ts' + +const SUMMARY_EXCLUDED_KEYS = ['turnOutline', 'sessionStats', 'todos', 'imageLimits'] as const + +const ownedContexts = new Set() +afterEach(async () => { + await Promise.all([...ownedContexts].map(ctx => ctx.fiber.dispose())) + ownedContexts.clear() +}) + +const remote = (ctx: Context) => createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), + cwd: '/tmp', +}) + +/** Read and close one snapshot-first follow generation. */ +async function opening( + ctx: Context, + sessionId: SessionId, +): Promise> { + const abort = new AbortController() + const iterator = remote(ctx).follow({ address: { kind: 'session', sessionId } }, abort.signal)[Symbol.asyncIterator]() + const first = await iterator.next() + abort.abort() + await iterator.return?.() + if (first.done || first.value.type !== 'snapshot') throw new Error('follow did not open with a snapshot') + return first.value +} + +/** + * Mount the registry plus the four summary-excluded units and one ordinary + * wire unit. The tool plugin needs a tool registry; the list path never + * executes a tool, so a registration sink is enough. + */ +async function harness(): Promise<{ ctx: Context; sessionContext: Context }> { + const ctx = new Context() + ownedContexts.add(ctx) + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionProjectionRegistry) + ctx.provide('tools', { register: () => () => {} } as never) + ctx.sessionProjections.register(agentPresetProjectionDefinition) + await ctx.plugin({ name: turnOutlineName, inject: turnOutlineInject, apply: turnOutlineApply }) + await ctx.plugin({ name: sessionStatsName, inject: sessionStatsInject, apply: sessionStatsApply }) + await ctx.plugin({ + name: toolTodoName, + inject: toolTodoInject, + Config: toolTodoConfig, + apply: toolTodoApply, + }, { allowParallelInProgress: false }) + // Build the remote (and with it the list owner that registers + // `sessionListMetadata`) before any Session exists, so every registered unit + // seeds a cell at `session/created`. + remote(ctx) + await new Promise(resolve => setTimeout(resolve, 0)) + return { ctx, sessionContext: ctx } +} + +/** One Session with a closed turn, a tool-free todo write, and a preset. */ +function seedSession(ctx: Context): { id: SessionId } { + const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace', agentPreset: 'standard' } }) + session.append('turn/start', { turn: 1 }) + session.append('todo/write', { todos: [{ content: 'ship it', status: 'in_progress' }] }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + return { id: session.id } +} + +describe('session.list summary payload', () => { + it('carries every registered wire key except the summary-excluded units', async () => { + const { ctx } = await harness() + const { id } = seedSession(ctx) + const session = ctx.sessions.get(id) + if (session === undefined) throw new Error('session was not created') + const everyWireKey = Object.keys(ctx.sessionProjections.snapshot(session).values) + // The fixture must actually exercise the excluded units. + for (const key of SUMMARY_EXCLUDED_KEYS) expect(everyWireKey).toContain(key) + + expect([...ctx.sessionProjections.summaryKeys()]).not.toContain('turnOutline') + + const response = await remote(ctx).list({}) + if (!response.ok) throw new Error('unreachable') + const row = response.value.items.find(item => item.sessionId === id) + const served = Object.keys(row?.projections?.values ?? {}) + expect(served.sort()).toEqual(everyWireKey.filter(key => !SUMMARY_EXCLUDED_KEYS.includes( + key as (typeof SUMMARY_EXCLUDED_KEYS)[number], + )).sort()) + for (const key of SUMMARY_EXCLUDED_KEYS) expect(served).not.toContain(key) + // An ordinary wire unit is still there with its value. + expect(row?.projections?.values.sessionListMetadata).toMatchObject({ blank: false }) + }) + + it('keeps the excluded units on an opened Session\'s follow snapshot', async () => { + const { ctx } = await harness() + const { id } = seedSession(ctx) + const session = ctx.sessions.get(id) + if (session === undefined) throw new Error('session was not created') + const everyValue = ctx.sessionProjections.snapshot(session).values + + const snapshot = await opening(ctx, id) + for (const key of SUMMARY_EXCLUDED_KEYS) { + expect(snapshot.projections.values).toHaveProperty(key) + expect(snapshot.projections.values[key as keyof typeof snapshot.projections.values]) + .toEqual(everyValue[key as keyof typeof everyValue]) + } + expect(snapshot.projections.values.todos).toEqual([{ content: 'ship it', status: 'in_progress' }]) + expect(snapshot.projections.values.imageLimits).toEqual(ctx.attachments.imageLimits) + }) + + it('drops the excluded units from the list while a Session is still attached', async () => { + const { ctx } = await harness() + const { id } = seedSession(ctx) + // Two consecutive pulls: a refresh must not reintroduce a dropped unit. + for (const attempt of [0, 1]) { + const response = await remote(ctx).list({}) + if (!response.ok) throw new Error('unreachable') + const row = response.value.items.find(item => item.sessionId === id) + expect(row?.projections?.values, `attempt ${String(attempt)}`).not.toHaveProperty('turnOutline') + expect(row?.projections?.values, `attempt ${String(attempt)}`).not.toHaveProperty('sessionStats') + expect(row?.projections?.values, `attempt ${String(attempt)}`).not.toHaveProperty('todos') + expect(row?.projections?.values, `attempt ${String(attempt)}`).not.toHaveProperty('imageLimits') + } + }) +}) diff --git a/packages/api/session-controller/tests/summary-types.client.spec.ts b/packages/api/session-controller/tests/summary-types.client.spec.ts new file mode 100644 index 0000000000..3900aebfd4 --- /dev/null +++ b/packages/api/session-controller/tests/summary-types.client.spec.ts @@ -0,0 +1,43 @@ +/** + * Type-level contract of the Session summary projection values: the list row + * carries every view value except the units that declare `summary: false`, + * while an opened Session's full projection snapshot keeps all of them. + */ + +import { describe, expect, it } from 'vitest' +import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/types' +import type {} from '@deepseek-ai/dsh-session-stats/types' +import type {} from '@deepseek-ai/dsh-session-turn-outline/types' +import type { SessionSummary } from '../src/client/sessions/service.ts' +import type { SessionProjectionSnapshot } from '../src/client/sessions/manager.ts' + +/** + * Compile-time contract, never called: each `@ts-expect-error` fails the + * typecheck gate if a summary value type ever regains an excluded key. + * @param summary - a Host list row summary. + * @param full - an opened Session's complete projection snapshot. + */ +function contract(summary: SessionSummary, full: SessionProjectionSnapshot): void { + // @ts-expect-error turnOutline is excluded from Session summary values + void summary.projectionValues?.turnOutline + // @ts-expect-error sessionStats is excluded from Session summary values + void summary.projectionValues?.sessionStats + // @ts-expect-error todos is excluded from Session summary values + void summary.projectionValues?.todos + // @ts-expect-error imageLimits is excluded from Session summary values + void summary.projectionValues?.imageLimits + + // Included keys stay readable, and the full snapshot keeps the excluded ones + // (with `todos` still nullable). + const title: string | null | undefined = summary.projectionValues?.title + const outline: readonly unknown[] | undefined = full.values.turnOutline + const todos: readonly TodoItem[] | null | undefined = full.values.todos + const limits = full.values.imageLimits + void [title, outline, todos, limits] +} + +describe('session summary projection types', () => { + it('declares the excluded keys on the list row type only through the full snapshot', () => { + expect(contract).toBeTypeOf('function') + }) +}) diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 61e5a89586..c88a4d6f9c 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -9,8 +9,8 @@ en: 02bd72c12ada3218 zh: 331b3a38b3ddfc6e /deepseek-ai-dsh-client-ui-chat/summary: - en: fba5b2fb8080e2dd - zh: 32c337123b7dfbcb + en: 4f2d50107db6c4f8 + zh: ea7ccfcce8f1849e /deepseek-ai-dsh-client-ui-chat/table-of-contents: en: 41cd21266011d571 zh: 3172d1a2acbb27b7 @@ -30,11 +30,11 @@ en: b4cd36e7ab91281e zh: 8ccf412b9dde61d5 /deepseek-ai-dsh-client-ui-chat/turn-process-folding: - en: 4cd7c81d70d2f365 - zh: 18eca6d16e44b6c2 + en: b0d806604bc17006 + zh: 18dabe5dc2d0d419 /deepseek-ai-dsh-client-ui-chat/grouped-rendering: - en: 81d7ca7595c34d39 - zh: 29eb8248c75e9ea6 + en: 408664597c1480ed + zh: 287a9b981337f288 /deepseek-ai-dsh-client-ui-chat/scroll-ownership: en: 284737900cb75668 zh: 12e0844082ff79ce diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 312796cdb9..325d767dbc 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -10,7 +10,7 @@ Desktop product events use the optional [product analytics service](../product-a ## Summary -Use this package to render a browser chat from recorded Session conversations, including historical images, localized actions, and restored scroll position. Work-details modes control reasoning previews and process visibility without hiding final answers; Verbose keeps completed-turn process rows visible. Local transcript and steering submissions appear immediately, remain in their original surface, and disappear atomically when authoritative Session records arrive, while queued submissions stay outside Chat. The package does not assemble or modify model requests. +Use this package to render a browser chat from recorded Session conversations, including historical images, localized actions, and restored scroll position. Work-details modes control reasoning previews and process visibility without hiding Assistant replies; Verbose keeps completed-turn process rows visible. Local transcript and steering submissions appear immediately, remain in their original surface, and disappear atomically when authoritative Session records arrive, while queued submissions stay outside Chat. The package does not assemble or modify model requests. File-mention providers receive the viewed Session ID with the closing-turn owner, so links into inherited history can address the fork itself. @@ -90,9 +90,9 @@ During uninterrupted following, local transcript and steering echoes remain moun When Chat ends with an open Turn control and that Turn has no visible input, the first local transcript echo precedes the control. Other echoes remain at the flow tail. The control and echoes share one keyed list, so arrival of the control preserves the echo's mounted identity. Durable inputs replace their matching echoes in the same render. -Work-details modes control process-group display and reasoning previews. Compact, Standard, and Detailed fold eligible completed Turns without hiding the final answer; Verbose retains the duration/status header without a collapse action and shows historical process rows directly. The [business-rule reference](src/client/conversation-nodes/README.md#display-modes) contains the mode table, title behavior, whole-Turn eligibility, clocks, and disclosure resets. +Work-details modes control process-group display and reasoning previews. Compact, Standard, and Detailed fold eligible completed Turns while keeping every Assistant reply visible, or only the final answer under Final answer only; Verbose retains the duration/status header without a collapse action and shows historical process rows directly. The [business-rule reference](src/client/conversation-nodes/README.md#display-modes) contains the mode table, title behavior, whole-Turn eligibility, clocks, and disclosure resets. -The Settings row below Work details offers an [independent collapse-timing preference](src/client/conversation-nodes/README.md#collapse-timing), saved through the same Host profile configuration. +The Settings rows below Work details offer an [independent collapse-timing preference](src/client/conversation-nodes/README.md#collapse-timing) and a [Replies After Collapse preference](src/client/conversation-nodes/README.md#folded-replies) (`ui-chat.foldedReplies`, hidden in Verbose), both saved through the same Host profile configuration. ----- @@ -109,7 +109,7 @@ Live tool deltas share reasoning's frame-batched publication; durable calls and The process group uses a stable `div` layout box, a scroll body, and an uncapped content box that reports growth inside the body. Business styles must adapt spacing within and across groups, including hidden or empty members and the answer-spacing exception. The `conversation.chat.flow` slot adds a `display: contents` DOM anchor; row selectors match that anchor directly, without repeating its column ancestry. The running status shares the rows' parent, so its divider uses the same visible-sibling rules. The anchor contributes no spacing of its own. CSS variables do not belong in the Group Definition. -Running group titles, reasoning previews, tool and command rows, the active retry line, and the bottom running status use the shared [TextShimmer](../ui-primitives/README.md#component-catalog). Each row’s text shares one left-to-right highlight, including collapsed group titles; icons and hover chevrons retain their base appearance. Tool and process rows sit 6px apart. An expanded group title has 8px before its content, Assistant responses have 12px from adjacent process rows, and completed-Turn duration/status controls have 16px around them. Hidden and empty rows, including retained empty slot anchors, add no gap. The running “Deep diving” text retains its blue base while its theme-specific highlight sweeps across it. +Running group titles, reasoning previews, tool and command rows, the active retry line, and the bottom running status use the shared [TextShimmer](../ui-primitives/README.md#component-catalog). Each row’s text shares one left-to-right highlight, including collapsed group titles; icons and hover chevrons retain their base appearance. Tool and process rows sit 6px apart. An expanded group title has 8px before its content, Assistant responses have 12px from adjacent process rows, and completed-Turn duration/status controls have 16px from their input and from a directly following visible row. A reply that follows the control after folded rows keeps the 12px reply gap, so folding never changes its spacing. Hidden and empty rows, including retained empty slot anchors, add no gap. The running “Deep diving” text retains its blue base while its theme-specific highlight sweeps across it. The running status shows a whale whose APNG mask inherits the text color. CSS selects the static SVG when reduced motion or forced colors are active, or alpha masks are unsupported. The build embeds the image in the plugin stylesheet; React does not pass image data through DOM styles. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 86d3b218d5..fb4d37df81 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -10,7 +10,7 @@ kind: "package-reference" ## 概述 -使用本包可在浏览器中渲染已记录的 Session 对话,包括历史图片、本地化操作和滚动位置恢复。工作步骤展示模式控制思考预览与过程显隐,不隐藏最终答案;完全展开模式保持已完成轮次的过程行可见。本地 transcript(文本记录)与 steering(中途引导)提交会立即显示并保留在原区域,在权威会话记录到达时原子地消失,而排队中的提交始终不进入 Chat。本包不组装或修改模型请求。 +使用本包可在浏览器中渲染已记录的 Session 对话,包括历史图片、本地化操作和滚动位置恢复。工作步骤展示模式控制思考预览与过程显隐,不隐藏 Assistant 回复;完全展开模式保持已完成轮次的过程行可见。本地 transcript(文本记录)与 steering(中途引导)提交会立即显示并保留在原区域,在权威会话记录到达时原子地消失,而排队中的提交始终不进入 Chat。本包不组装或修改模型请求。 文件提及提供方同时接收当前查看的会话 ID 与收尾轮次的属主信息,因此继承历史中的链接可以指向 fork 自身。 @@ -90,9 +90,9 @@ Assistant 尝试结束且没有可见消息时,Chat 隐藏已发布的 Node, Chat 末尾为进行中的 Turn 控制行,且该轮尚无可见输入时,第一条本地 transcript 回显显示在控制行前。其他回显保留在正文末尾。控制行与回显共用一个 keyed 列表,因此控制行到达时不会重新挂载回显。持久输入在同一次渲染中替换匹配的回显。 -工作步骤展示模式控制过程组显示与推理预览。简洁、标准、详细模式收起符合条件的已完成轮次,不隐藏最终答案;完全展开模式保留时长或状态抬头,但不支持收起,历史过程行直接显示。[业务规则明细](src/client/conversation-nodes/README.zh.md#display-modes) 统一说明模式表、组头行为、整轮折叠资格、时钟与开合重置。 +工作步骤展示模式控制过程组显示与推理预览。简洁、标准、详细模式收起符合条件的已完成轮次,保留所有 Assistant 回复可见,选「仅最终回答」时只保留最终答案;完全展开模式保留时长或状态抬头,但不支持收起,历史过程行直接显示。[业务规则明细](src/client/conversation-nodes/README.zh.md#display-modes) 统一说明模式表、组头行为、整轮折叠资格、时钟与开合重置。 -工作步骤展示下方的设置行提供[独立的收起时机设置](src/client/conversation-nodes/README.zh.md#collapse-timing),通过相同的 Host profile 配置保存。 +工作步骤展示下方的设置行提供[独立的收起时机设置](src/client/conversation-nodes/README.zh.md#collapse-timing)和[「收起后显示的回复」设置](src/client/conversation-nodes/README.zh.md#folded-replies)(`ui-chat.foldedReplies`,完全展开模式下隐藏),二者都通过相同的 Host profile 配置保存。 ----- @@ -109,7 +109,7 @@ Chat Builder 按节点发布 `bottomSource`:节点可见且属于已加载的 过程组使用稳定的 `div` 布局盒子、滚动正文及不限高的内容盒子,后者报告正文内部的内容增长。业务样式必须适配组内及组边界间距,处理隐藏或空成员以及回答前的间距特例。`conversation.chat.flow` 插槽添加一个 `display: contents` DOM 锚点;行选择器直接匹配该锚点,不重复匹配其正文容器祖先。运行状态与各行共享父级,使分隔线使用同一套可见兄弟节点规则。锚点自身不产生间距。CSS 变量不属于 Group Definition。 -运行中的分组标题、思考预览、工具与命令行、当前重试行以及底部运行状态使用共享的 [TextShimmer](../ui-primitives/README.zh.md#component-catalog)。每行的文字共用一道从左到右的高光,包括折叠的分组标题;图标和悬停箭头保留底色。工具和过程行之间相隔 6px;展开的分组标题与内容之间相隔 8px;Assistant 回复与相邻过程行之间相隔 12px;每轮完成/耗时控件周围保留 16px。隐藏行与空行(包括保留的空 slot 锚点)不产生间距。“深度求索”运行文字保留蓝色底色,并使用主题专用高光扫过文字。 +运行中的分组标题、思考预览、工具与命令行、当前重试行以及底部运行状态使用共享的 [TextShimmer](../ui-primitives/README.zh.md#component-catalog)。每行的文字共用一道从左到右的高光,包括折叠的分组标题;图标和悬停箭头保留底色。工具和过程行之间相隔 6px;展开的分组标题与内容之间相隔 8px;Assistant 回复与相邻过程行之间相隔 12px;每轮完成/耗时控件与其输入、以及紧随其后的可见行之间保留 16px。控件与回复之间隔着已收起的行时,回复保持 12px 回复间距,因此折叠不会改变它的间距。隐藏行与空行(包括保留的空 slot 锚点)不产生间距。“深度求索”运行文字保留蓝色底色,并使用主题专用高光扫过文字。 运行状态显示一只鲸鱼,其 APNG 遮罩继承文字颜色。启用减少动态效果或强制颜色模式、或浏览器不支持 alpha 遮罩时,CSS 选择静态 SVG。构建将图片嵌入插件样式表;React 不通过 DOM 样式传递图片数据。 diff --git a/packages/client/ui-chat/src/chat-settings.ts b/packages/client/ui-chat/src/chat-settings.ts index 8e76bb29ed..ae9333ba37 100644 --- a/packages/client/ui-chat/src/chat-settings.ts +++ b/packages/client/ui-chat/src/chat-settings.ts @@ -20,6 +20,18 @@ export type CollapseTiming = typeof COLLAPSE_TIMINGS[number] /** Preserve completion-time folding when no timing preference is saved. */ export const DEFAULT_COLLAPSE_TIMING: CollapseTiming = 'completion' +/** Field carrying which Assistant replies stay visible in a folded Turn. */ +export const FOLDED_REPLIES_FIELD = 'foldedReplies' + +/** Assistant replies kept visible while a completed Turn folds its process: every reply, or only the final answer. */ +export const FOLDED_REPLIES = ['all', 'final'] as const + +/** Which Assistant replies stay visible while a completed Turn folds its process. */ +export type FoldedReplies = typeof FOLDED_REPLIES[number] + +/** Keep every Assistant reply visible when no folded-reply preference is saved. */ +export const DEFAULT_FOLDED_REPLIES: FoldedReplies = 'all' + /** Work-details presentation modes a user can choose. */ export const TRANSCRIPT_VIEW_MODES = ['compact', 'standard', 'detailed', 'verbose'] as const @@ -64,6 +76,8 @@ export interface ChatSettings { transcriptView?: TranscriptViewMode | typeof LEGACY_TRANSCRIPT_VIEW_MODE | typeof LEGACY_EXPANDED_TRANSCRIPT_VIEW_MODE | null /** Independent collapse timing; absence preserves completion-time folding. */ collapseTiming?: CollapseTiming + /** Replies kept visible in a folded Turn; absence keeps every reply. */ + foldedReplies?: FoldedReplies /** Detail level for composer statistics and completed-Turn usage. */ performanceUsage: PerformanceUsageMode /** Default destination for Chat HTTP(S) links. */ @@ -73,6 +87,7 @@ export interface ChatSettings { /** Durable Chat schema; also the wire envelope the browser scope validates against. */ export const ChatSettingsFields = { [COLLAPSE_TIMING_FIELD]: z.union([...COLLAPSE_TIMINGS]).default(DEFAULT_COLLAPSE_TIMING), + [FOLDED_REPLIES_FIELD]: z.union([...FOLDED_REPLIES]).default(DEFAULT_FOLDED_REPLIES), linkOpening: z.union(['sidebar', 'new-tab']).default(DEFAULT_LINK_OPENING), performanceUsage: z.union([...PERFORMANCE_USAGE_MODES]).default(DEFAULT_PERFORMANCE_USAGE), // Missing and unrecognized modes defer to the client's default. diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index 8aa63aa1f7..9421bc9020 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -165,7 +165,7 @@ export function apply(ctx: Context): void { }, LinkOpeningRow)) }) const transcriptView = new TranscriptViewPolicy(chatSettings, 'dshDesktop' in globalThis ? 'standard' : DEFAULT_TRANSCRIPT_VIEW_MODE) - const presentation = derivePresentationPolicy(transcriptView.mode, transcriptView.collapseTiming) + const presentation = derivePresentationPolicy(transcriptView.mode, transcriptView.collapseTiming, transcriptView.foldedReplies) const performancePolicy = new PerformanceUsagePolicy(chatSettings) ctx.effect(() => () => { transcriptView.dispose(); performancePolicy.dispose() }) const performanceUsage = performancePolicy.mode @@ -192,9 +192,14 @@ export function apply(ctx: Context): void { order: 12, locale: NS, inject: (): TranscriptViewRowInjected => ({ - hooks: { transcriptView: transcriptView.mode, collapseTiming: transcriptView.collapseTiming }, + hooks: { + transcriptView: transcriptView.mode, + collapseTiming: transcriptView.collapseTiming, + foldedReplies: transcriptView.foldedReplies, + }, setTranscriptView: (mode) => { transcriptView.setMode(mode) }, setCollapseTiming: (timing) => { transcriptView.setCollapseTiming(timing) }, + setFoldedReplies: (replies) => { transcriptView.setFoldedReplies(replies) }, }), }, TranscriptViewRow)) diff --git a/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx b/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx index 5f61fe7007..9f8cf6cbe6 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx @@ -25,10 +25,13 @@ export const AssistantNodeView = memo(function AssistantNodeView({ () => owner === undefined ? undefined : fileMentions(owner), [fileMentions, owner], ) + const keepReplies = usePresentation(policy => policy.foldedReplies === 'all') + // A kept reply row stays visible while the folded Turn hides its inline reasoning. const reasoningHidden = turnProcess !== undefined && turnProcess.foldable - && turnProcess.spec.answerStep === data.step - && turnProcess.spec.inlineReasoning + && (keepReplies + ? groupPart !== 'reasoning' + : turnProcess.spec.answerStep === data.step && turnProcess.spec.inlineReasoning) && !turnProcess.open const revealProcess = useCallback(() => { turnProcess?.setOpen(true) }, [turnProcess]) return ( diff --git a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx index 9721b4a0a8..6db2f75821 100644 --- a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx @@ -3,6 +3,7 @@ import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { ConversationLocationDataStore, ConversationTurnDataMap } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ChatNodeHookContext, ChatNodeOwnerProps, ChatFlowSlotProps, UsePresentation } from '../contract/slots.ts' +import { hasAssistantReplyContent } from '../contract/assistant-content.ts' import type { ChatNode } from '../contract/chat-nodes.ts' import type { ChatNodeStore } from '../contract/snapshot.ts' import { isAtOrOutside } from '../contract/group-tree.ts' @@ -69,14 +70,21 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ } }, [actions, processSpec, alwaysOpen]) const foldCompleted = usePresentation(policy => policy.foldCompletedTurns) + const keepReplies = usePresentation(policy => policy.foldedReplies === 'all') // A loaded end makes a partial historical Turn eligible without its start. const processWindowReady = processSpec !== undefined && processPresentation !== undefined && foldCompleted && processPresentation.turn === processSpec.turn && (processPresentation.turnStarted || processPresentation.turnClosed) + // With every reply kept, an Assistant reply stays outside whole-Turn folding; its reasoning remains process content. + const replyRow = keepReplies + && routedNode?.kind === 'assistant-step' + && groupPart !== 'reasoning' + && hasAssistantReplyContent(routedNode.data.blocks) const processMember = routedNode !== undefined && processWindowReady + && !replyRow && !isAtOrOutside(routedNode, 'turn') && routedNode.anchorSeq >= processSpec.processStartSeq && (liveProcess || processSpec.answerAnchorSeq === null || routedNode.anchorSeq < processSpec.answerAnchorSeq @@ -87,7 +95,7 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ && groupPart !== 'reasoning' && routedNode.kind === 'assistant-step' && routedNode.data.step === processSpec.answerStep - const ownsDisclosure = routedNode?.kind === 'turn-process' || processAnswer + const ownsDisclosure = routedNode?.kind === 'turn-process' || processAnswer || replyRow const foldable = processWindowReady && (liveProcess || processMember || ownsDisclosure) const turnProcess = useMemo(() => processSpec === undefined @@ -95,18 +103,17 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ : { spec: processSpec, foldable, - hasContent: !interleavedInput && (processPresentation?.hasExternalProcess === true || processSpec.inlineReasoning), + hasContent: !interleavedInput && (processSpec.inlineReasoning || (keepReplies + ? processPresentation?.hasNonReplyProcess === true + : processPresentation?.hasExternalProcess === true)), open: processOpen, setOpen, }, [ - foldable, interleavedInput, processOpen, processSpec, processPresentation?.hasExternalProcess, setOpen, + foldable, interleavedInput, keepReplies, processOpen, processSpec, + processPresentation?.hasExternalProcess, processPresentation?.hasNonReplyProcess, setOpen, ]) const controllerInactive = routedNode?.kind === 'turn-process' && foldCompleted && !foldable - const compactAnswer = processAnswer - && foldable - && processPresentation.compactAnswer - && !processOpen const processHidden = controllerInactive || (foldable && processMember && !processOpen) const revealProcess = useCallback(() => { if (processMember) setOpen(true) @@ -156,7 +163,6 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ data-chat-turn={turn} data-turn-process-member={processMember || undefined} data-turn-process-hidden={processHidden || undefined} - data-turn-process-answer={compactAnswer || undefined} > {renderSlot('conversation.chat.node', routedOwner, { entryKey: routedNode.kind, diff --git a/packages/client/ui-chat/src/client/chat/ChatView.module.css b/packages/client/ui-chat/src/client/chat/ChatView.module.css index a486b8c48d..319d26100d 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.module.css +++ b/packages/client/ui-chat/src/client/chat/ChatView.module.css @@ -117,7 +117,6 @@ running Turn's control renders empty until the Turn closes; an echo after it is still an opening input of that Turn, so it keeps the input gap. */ .flowItem[data-chat-flow-kind="turn-process"], -.flowItem[data-turn-process-answer], :is(.column, :global([data-slot="conversation.chat.flow"])) > [data-chat-flow-kind="turn-process"]:not(:empty, :has(> [data-slot="conversation.chat.node"]:empty)) + :not([hidden]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty))):not(.running):not(:has(> [data-slot="conversation.chat.node"] > [data-chat-steering-batch])), :is(.column, :global([data-slot="conversation.chat.flow"])) > [data-chat-flow-kind="turn-process"] + :not([hidden]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty))):not(.running):not([data-submission-echo], [data-pending-steering]):not(:has(> [data-slot="conversation.chat.node"] > [data-chat-steering-batch])) { --dsh-chat-flow-gap: 16px; diff --git a/packages/client/ui-chat/src/client/contract/group-tree.ts b/packages/client/ui-chat/src/client/contract/group-tree.ts index 961a0ebb8f..237afd9f6a 100644 --- a/packages/client/ui-chat/src/client/contract/group-tree.ts +++ b/packages/client/ui-chat/src/client/contract/group-tree.ts @@ -17,10 +17,10 @@ import type { ChatConversationViewNode } from './chat-nodes.ts' * `assistant-step` Node places its rows without one: its reasoning row joins * the step run and its reply row stands outside it. * - * Whole-Turn folding also exempts the finalized answer's reply. That exemption - * comes from the Turn's process specification (`answerAnchorSeq` and - * `answerStep`), not from a position, because which step answers changes as - * later steps arrive. + * Whole-Turn folding also exempts reply rows: with the default Replies After + * Collapse choice every `assistant-step` reply row stays visible, and with + * Final answer only just the final answer's reply row does. Reasoning rows + * stay in the folded process in both choices. */ export type ChatGroupTree = 'exchange' | 'input' | 'turn' | 'process' diff --git a/packages/client/ui-chat/src/client/contract/snapshot.ts b/packages/client/ui-chat/src/client/contract/snapshot.ts index 7e5f5038d3..ea3c9707a9 100644 --- a/packages/client/ui-chat/src/client/contract/snapshot.ts +++ b/packages/client/ui-chat/src/client/contract/snapshot.ts @@ -92,9 +92,10 @@ export interface ChatTurnProcessPresentation { readonly turnStarted: boolean readonly turnClosed: boolean readonly hasExternalProcess: boolean + /** Like `hasExternalProcess`, but a reply from another Step counts only through its reasoning. */ + readonly hasNonReplyProcess: boolean /** A visible input after process output prevents one disclosure from hiding its surrounding groups. */ readonly hasInterleavedInput: boolean - readonly compactAnswer: boolean } /** Compatibility projection backing the composer stats pills (StatsPills.tsx) and the legacy top-level snapshot fields. */ diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml b/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml index 00bfd99f6a..52813c0e93 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml @@ -12,20 +12,20 @@ en: 660130914d717369 zh: 7e0161dbfe788e96 /chat-conversation-node-rules/table-of-contents: - en: 8d953a0b8776ef49 - zh: b5d9fb42d0eb0630 + en: a867dae21e71d327 + zh: c33b815f08497a82 /chat-conversation-node-rules/how-one-turn-becomes-a-transcript: - en: 4248ac4fbe0876ff - zh: 620c8eb9ea8e5a15 + en: 8598bb1e29a37e02 + zh: d122146ab82568e9 /chat-conversation-node-rules/how-one-turn-becomes-a-transcript/a-complete-example: - en: 82d9651b9d284c1f - zh: ec1adc15b33a27cf + en: 42cab271580c5b42 + zh: ff81eb1785e55455 /chat-conversation-node-rules/how-one-turn-becomes-a-transcript/what-the-reader-sees: - en: 91b35cbebe8c2a1d - zh: a4fee6d7102bc874 + en: 75564701ecd3d1bd + zh: 9c042288a15d441d /chat-conversation-node-rules/whole-turn-folding: - en: b3c769cdc1d9ac00 - zh: 1c31066c7f7e803d + en: f434416f393641fc + zh: 9ef356d6a264cf05 /chat-conversation-node-rules/display-modes: en: ada5a60d2c46f6a8 zh: f3eaeb5cb4d9b3de @@ -33,11 +33,14 @@ en: b25bf00ce3a6a119 zh: 0ab47a4014ea5e1e /chat-conversation-node-rules/display-modes/collapse-timing: - en: ad74c1a610fc50d2 - zh: 13de05ffedf63f0c + en: 34fceb582301b4f8 + zh: 23bea929ac798bb5 +/chat-conversation-node-rules/display-modes/replies-after-collapse: + en: 8e775c43878ae31a + zh: 4c1063c0071ebdbd /chat-conversation-node-rules/display-modes/group-title-rules: - en: 2b23813edc87faed - zh: bc5ddb49c9d128e7 + en: 8c5b57a66fe0247c + zh: 5a9e37f255761ac6 /chat-conversation-node-rules/process-grouping: en: 3d28b3bb8757bf55 zh: 3b0f451b48db6048 diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.md b/packages/client/ui-chat/src/client/conversation-nodes/README.md index d4331c0175..ae9012ef9c 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.md +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.md @@ -15,6 +15,7 @@ A Turn shows an answer and lets the reader inspect the work behind it. Start wit - [Whole-Turn folding](#whole-turn-folding) - [Display modes](#display-modes) - [Collapse timing](#collapse-timing) +- [Replies after collapse](#folded-replies) - [Process grouping](#process-grouping) - [Activity summaries and tool rules](#activity-summaries) @@ -23,7 +24,7 @@ A Turn shows an answer and lets the reader inspect the work behind it. Start wit ## How one Turn becomes a transcript -Grouping, counts, display modes, and whole-Turn folding answer different questions. Grouping decides which process rows belong together. Counts summarize work inside an existing group. Display modes decide how much of each group to show. Without an intervening input, whole-Turn folding can hide the entire process across several groups and intermediate replies. +Grouping, counts, display modes, and whole-Turn folding answer different questions. Grouping decides which process rows belong together. Counts summarize work inside an existing group. Display modes decide how much of each group to show. Without an intervening input, whole-Turn folding can hide the entire process across several groups while every reply stays visible. ### A complete example @@ -33,12 +34,12 @@ Suppose the assistant thinks, reads a file, runs a command, posts a progress rep - Whole-Turn control. - Process content controlled by the whole-Turn control: - Group G1: reasoning, `read`, `bash`. - - Intermediate reply: outside both groups, but still part of the Turn's process. - Group G2: reasoning, `run_code`. +- Intermediate reply between G1 and G2, outside both groups and outside the process fold. - Final response, outside the process fold. - Completed-turn footer. -Every reply separates secondary groups; only the final answer is protected from whole-Turn folding. An intermediate reply can be an independent row and still disappear when the Turn is folded. Reasoning attached to the final answer remains process content. +Every reply separates secondary groups and, with the default [All replies](#folded-replies), stays outside whole-Turn folding, whichever Step it belongs to. Reasoning attached to any reply remains process content. | Question | Deciding rule | Result in this example | |---|---|---| @@ -46,7 +47,7 @@ Every reply separates secondary groups; only the final answer is protected from | What did a group do? | Category counts | G1 has `read=1, commands=1`; G2 has `code=1`. Reasoning adds no tool count. | | What does its title say? | Group state and summary | Running groups use their latest running activity; closed groups use their highest-count categories. Counts never decide folding. | | How much detail is visible? | Display mode and manual group opening | Compact, Standard, and Detailed retain historical group headers; Detailed shows running bodies directly, while Verbose shows both running and historical bodies directly. | -| Is the process visible at all? | Whole-Turn opening | Closing the Turn hides G1, the intermediate reply, and G2 together. | +| Is the process visible at all? | Whole-Turn opening | Closing the Turn hides G1 and G2 together; the intermediate reply and final response stay visible. | ### What the reader sees @@ -54,7 +55,7 @@ Assume the Turn above completed normally, with no inner disclosure manually open | Viewing state | Visible content | |---|---| -| Whole Turn collapsed, Compact/Standard/Detailed | Input, whole-Turn control, final response, and footer. No process headers, bodies, or intermediate reply. | +| Whole Turn collapsed, Compact/Standard/Detailed | Input, whole-Turn control, intermediate reply, final response, and footer. No process headers or bodies. | | Whole Turn open, Compact/Standard/Detailed | G1/G2 headers, the intermediate reply, and final response. Group bodies start collapsed. | | Whole Turn and G1 open, Standard or Detailed | G1's reasoning and tool rows with the settled reasoning preview; G2 remains a header. Full reasoning/tool bodies are still manual. | | Verbose | Whole-Turn duration/status header without a collapse action, all process rows, intermediate reply, and final response. No group headers; individual tool/reasoning bodies remain manual. | @@ -94,11 +95,11 @@ Trigger titles and icons use the recorded `source.kind`: `schedule`, `tool-jobs` | Neither start nor end is loaded | Keep process rows visible and withhold the whole-Turn control. | | A Turn control exists, but there is no process content | Retain its title, without a collapse action. | -The final answer is the latest Step's settled Assistant reply, provided it has visible reply content and no tool-call block. Its response remains outside whole-Turn folding; its reasoning remains process content. A Node whose [group-tree position](../contract/group-tree.ts) is `turn` or outer remains independent of whole-Turn folding. Turn inputs stand at `input` or `exchange`: user and steering messages and trigger notices; an input after the process start keeps the whole Turn open. Terminal errors, max-token notices, the completed-turn footer, and the Turn control stand at `turn`. A `process` Node, such as a model retry, separates secondary groups but whole-Turn folding still includes it. +With the default [All replies](#folded-replies), every Assistant reply remains outside whole-Turn folding; with Final answer only, only the final answer's reply does. Reasoning remains process content in both cases. The final answer is the latest Step's settled Assistant reply, provided it has visible reply content and no tool-call block. A Node whose [group-tree position](../contract/group-tree.ts) is `turn` or outer remains independent of whole-Turn folding. Turn inputs stand at `input` or `exchange`: user and steering messages and trigger notices; an input after the process start keeps the whole Turn open. Terminal errors, max-token notices, the completed-turn footer, and the Turn control stand at `turn`. A `process` Node, such as a model retry, separates secondary groups but whole-Turn folding still includes it. Loading an older page preserves the reader's group-opening choices. Newly loaded process content follows the same Turn state while the final answer is unchanged and whole-Turn folding remains eligible. If the page reveals an intervening input, individual group disclosures replace whole-Turn hiding. When the real start arrives, the duration becomes available; loading all history is not an additional folding condition. When new content only extends an existing group at its beginning, that group and its old message rows retain their identities and opening choices. Replies, steering, and other real boundaries in the new page still separate groups; not every new row joins the old group. -Clicking Load older anchors the first visible content item below that button in transcript order, regardless of its position in the viewport. A collapsed group anchors its header; an expanded group skips its header and anchors its first visible member. The whole-Turn process control is excluded because paging can move it ahead of newly loaded work and steering. When a whole Turn is collapsed, the anchor is its first remaining visible message, such as steering or the final answer. Ordinary messages and steering anchor themselves; hidden and empty rows are skipped. Loading more content inside a collapsed group keeps its header stationary; adding earlier groups or rows preserves that same group header while they appear above it. +Clicking Load older anchors the first visible content item below that button in transcript order, regardless of its position in the viewport. A collapsed group anchors its header; an expanded group skips its header and anchors its first visible member. The whole-Turn process control is excluded because paging can move it ahead of newly loaded work and steering. When a whole Turn is collapsed, the anchor is its first remaining visible message, such as steering or a reply. Ordinary messages and steering anchor themselves; hidden and empty rows are skipped. Loading more content inside a collapsed group keeps its header stationary; adding earlier groups or rows preserves that same group header while they appear above it. Paging adds older content above the retained anchor without jumping to the new top. A capped group absorbs the displacement within its scroll range; the outer transcript absorbs the remainder, including when the group first reaches its cap. If the available scroll range is insufficient, compensation stops at the actual limit without adding bottom space. Later content growth keeps the same anchor until a reading gesture or explicit navigation releases it. Typing or clicking within the composer and non-scrolling transcript keys retain the anchor. If the reader scrolls while a page is loading, scrolling takes priority and its settled reading position becomes the new paging anchor. @@ -144,6 +145,11 @@ When a new message triggers a fold, process details close in one movement with n Verbose retains its flat process rows under either timing. Failed, stopped, and interleaved-input Turns retain their existing whole-Turn eligibility rules; delayed folding can still postpone Detailed's conversion from flat rows to historical groups. + +### Replies after collapse + +Settings → General → Replies after collapse appears directly below Collapse work details and chooses which Assistant replies stay visible when a Turn's process folds. All replies is the default: every reply stays visible and only reasoning, tool calls, and step groups fold; a Turn whose range holds only replies offers no collapse action. Final answer only folds intermediate replies with the process and keeps only the final answer visible. The choice is saved as `ui-chat.foldedReplies` through the same Host profile settings and applies immediately to already rendered Turns without remounting their rows. Verbose never folds a Turn, so the setting is hidden while Verbose is selected. + ### Group-title rules A closed group's header names the first three categories from its ranked summary, without displaying counts. A group without categories uses the thinking label. A running header names its live tool category, otherwise thinking; Standard appends live detail. Live titles remain visible for at least 150ms, retaining only the newest pending title. @@ -181,7 +187,7 @@ The labels below describe recorded activity, not successful outcomes. For exampl English lowercases the initial letter of joined labels after the first. Closing a group immediately selects the completed summary; the 150ms minimum applies to running-title changes, not to delaying completion. Detailed hides group headers in running Turns, including groups ended by a reply or steering before their Turn ends. Verbose also hides historical group headers. -Group headers show a category icon, replace it with a down arrow on hover or keyboard focus, and show an up arrow while open. Manually expanded group bodies use 6px row spacing, a `min(400px, 50vh)` height cap, and 24px directional fades. Wheel scrolling can continue into the outer transcript at an edge. Detailed removes the group-level cap in running Turns; Verbose applies this layout to historical Turns as well. Process rows retain 6px spacing in both modes; Assistant responses have 12px clearance. Group headers have the same row height as process rows. Expanded group titles sit 8px above their content; Turn process controls retain 16px clearance. +Group headers show a category icon, replace it with a down arrow on hover or keyboard focus, and show an up arrow while open. Manually expanded group bodies use 6px row spacing, a `min(400px, 50vh)` height cap, and 24px directional fades. Wheel scrolling can continue into the outer transcript at an edge. Detailed removes the group-level cap in running Turns; Verbose applies this layout to historical Turns as well. Process rows retain 6px spacing in both modes; Assistant responses have 12px clearance. Group headers have the same row height as process rows. Expanded group titles sit 8px above their content; Turn process controls retain 16px clearance from their input and from a directly following visible row; a reply separated from the control by folded rows keeps the 12px reply gap, so folding never changes its spacing. An open capped group follows content growth only while its own scroll position is at the bottom. Scrolling away pauses that group's following; returning to the bottom resumes it, independently of outer transcript following. Manually opening an unclosed group starts at the bottom and follows growth; manually opening a closed group starts at the top with following disabled, even when its initial content fits without scrolling. Closing the group in the data or restoring its height cap through a mode change does not reset an already-open reader's position. Browser find retains its own reveal position. diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md b/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md index 28d6076214..08222480e1 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md @@ -15,6 +15,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" - [整轮折叠](#whole-turn-folding) - [展示模式](#display-modes) - [收起时机](#collapse-timing) +- [收起后显示的回复](#folded-replies) - [过程分组](#process-grouping) - [活动摘要与工具明细](#activity-summaries) @@ -23,7 +24,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" ## 先理解一轮对话怎样显示 -分组、类别计数、展示模式和整轮折叠分别回答不同问题:分组决定哪些过程行放在一起;计数概括一个已有组里做了什么;展示模式决定组内显示多少;没有中途输入打断时,整轮折叠可以一次隐藏跨越多个组和阶段回复的全部过程。 +分组、类别计数、展示模式和整轮折叠分别回答不同问题:分组决定哪些过程行放在一起;计数概括一个已有组里做了什么;展示模式决定组内显示多少;没有中途输入打断时,整轮折叠可以一次隐藏跨越多个组的全部过程,回复始终可见。 ### 一个完整例子 @@ -33,12 +34,12 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" - 整轮控件。 - 由整轮控件控制的过程范围: - 过程组 G1:思考、`read`、`bash`。 - - 阶段回复:不属于 G1 或 G2,但仍属于整轮过程。 - 过程组 G2:思考、`run_code`。 +- G1 与 G2 之间的阶段回复,不属于任何组,也不随过程收起。 - 最终答案正文,不随过程收起。 - 已完成轮次页脚。 -每次回复都会截断二级组,但只有最终答案正文不参与整轮折叠。因此,阶段回复虽然是一行独立内容,整轮收起时仍会消失。最终答案附带的思考仍属于过程内容。 +每次回复都会截断二级组;在默认的[全部回复](#folded-replies)下,不论属于哪个 Step,回复都不参与整轮折叠。任何回复附带的思考仍属于过程内容。 | 问题 | 由什么决定 | 例子中的结果 | |---|---|---| @@ -46,7 +47,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" | 一组做了什么? | 类别计数 | G1 为 `read=1, commands=1`,G2 为 `code=1`;思考不增加工具次数。 | | 组标题写什么? | 组状态与摘要 | 未结束组显示最近运行活动;已结束组显示次数最多的类别。次数不决定折叠。 | | 组内显示多少细节? | 展示模式与手动开合 | 简洁、标准、详细模式保留历史组头;详细模式直接显示运行中正文,完全展开模式直接显示运行中与历史正文。 | -| 过程整体是否可见? | 整轮开合 | 整轮收起时,G1、阶段回复和 G2 一起隐藏。 | +| 过程整体是否可见? | 整轮开合 | 整轮收起时,G1 和 G2 一起隐藏;阶段回复与最终答案保持可见。 | ### 用户实际看到什么 @@ -54,7 +55,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" | 查看状态 | 可见内容 | |---|---| -| 整轮收起,简洁/标准/详细模式 | 输入、整轮控件、最终答案和页脚;过程组头、正文及阶段回复都不显示。 | +| 整轮收起,简洁/标准/详细模式 | 输入、整轮控件、阶段回复、最终答案和页脚;过程组头与正文不显示。 | | 整轮展开,简洁/标准/详细模式 | G1/G2 组头、阶段回复及最终答案;组正文初始收起。 | | 整轮和 G1 展开,标准或详细模式 | G1 的思考与工具行,思考带已结算预览;G2 仍是组头。单个完整正文仍需手动打开。 | | 完全展开模式 | 不可收起的整轮时长或状态抬头、全部过程行、阶段回复与最终答案。没有组头,单个工具/思考正文仍需手动展开。 | @@ -94,11 +95,11 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" | 起点与结束记录均未加载 | 保留过程行可见,不显示整轮控件。 | | 存在整轮控件,但没有过程内容 | 保留标题,不提供折叠操作。 | -最终答案取最后一个 Step 中已结算的 Assistant 回复,要求有可见回复内容且没有工具调用块。最终答案的回复正文不参与整轮折叠,推理仍属于过程。[分组树位置](../contract/group-tree.ts)为 `turn` 或更外层的 Node 不参与整轮折叠。轮次输入位于 `input` 或 `exchange`:用户与 steering 消息、触发通知;过程开始后出现的输入使整个轮次保持展开。终止错误、最大 token 提示、已完成轮次页脚和整轮控件位于 `turn`。`process` 位置的 Node(如模型重试)分隔二级分组,但整轮折叠仍包含它。 +在默认的[全部回复](#folded-replies)下,所有 Assistant 回复都不参与整轮折叠;选「仅最终回答」时只有最终答案的回复不参与。两种情况下推理都属于过程。最终答案取最后一个 Step 中已结算的 Assistant 回复,要求有可见回复内容且没有工具调用块。[分组树位置](../contract/group-tree.ts)为 `turn` 或更外层的 Node 不参与整轮折叠。轮次输入位于 `input` 或 `exchange`:用户与 steering 消息、触发通知;过程开始后出现的输入使整个轮次保持展开。终止错误、最大 token 提示、已完成轮次页脚和整轮控件位于 `turn`。`process` 位置的 Node(如模型重试)分隔二级分组,但整轮折叠仍包含它。 加载更早的一页保留用户的组级开合选择。最终答案未变化且仍符合整轮折叠条件时,新加载的过程内容沿用该轮的开合状态;若新页揭示中途输入,则改用各组开合,不再整轮隐藏。真正的起点到达后才显示时长,是否已加载完全部历史不是额外折叠条件。若新内容只是接在旧组前面,旧组和旧消息行保留身份及已有展开状态。新页出现回复、steering 等真实边界时仍按规则分组,不把所有新增内容强并进旧组。 -点击加载更早时,按正文顺序锚定按钮下的第一个可见内容项,不根据它在视口中的位置选择。组收起时选组 header;展开时跳过组 header,选内部第一个可见成员。整轮过程控件不作为锚点,因为分页可能将它移到新加载的过程及 steering 前面。整轮收起时,锚定第一个仍可见的消息,例如 steering 或最终答案。普通消息与 steering 选择自身,跳过隐藏和空行。新增内容全进入收起的组时,组 header 保持原位;前面出现更早的组或节点时,仍保持该组 header 的位置,让新增内容出现在它上方。 +点击加载更早时,按正文顺序锚定按钮下的第一个可见内容项,不根据它在视口中的位置选择。组收起时选组 header;展开时跳过组 header,选内部第一个可见成员。整轮过程控件不作为锚点,因为分页可能将它移到新加载的过程及 steering 前面。整轮收起时,锚定第一个仍可见的消息,例如 steering 或某条回复。普通消息与 steering 选择自身,跳过隐藏和空行。新增内容全进入收起的组时,组 header 保持原位;前面出现更早的组或节点时,仍保持该组 header 的位置,让新增内容出现在它上方。 分页把更早内容加到保留的锚点上方,不主动跳到新内容顶部。限高组先在自己的滚动范围内吸收位移,剩余部分由外层文本滚动补偿,包括组正文刚达到限高的情况。可滚动范围不足时,补偿停在实际边界,不增加底部空白。后续内容撑高沿用同一个锚点,直到阅读手势或显式导航释放它。在 composer 中打字或点击,以及 transcript 内的非滚动按键,保留锚点。分页请求期间若读者滚动,优先服从滚动,停止后以新的阅读位置作为分页锚点。 @@ -144,6 +145,11 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" 完全展开模式在两种时机下都保留平铺过程行。失败、停止和含插入输入的轮次沿用原有整轮折叠资格;延后折叠仍可推迟详细模式从平铺行转换为历史分组的时机。 + +### 收起后显示的回复 + +设置 → 通用设置 → 收起后显示的回复位于工作步骤收起时机正下方,选择过程收起时保留哪些 Assistant 回复。「全部回复」是默认值:所有回复保持可见,只收起思考、工具调用和步骤组;范围内只有回复的轮次不提供折叠操作。「仅最终回答」让阶段回复随过程一起收起,只保留最终答案。选择以 `ui-chat.foldedReplies` 保存到同一份 Host profile 设置,并立即作用于已渲染的轮次,不重建它们的行。完全展开模式从不折叠轮次,选中该模式时隐藏此设置。 + ### 组标题规则 已结束组的组头展示排序摘要的前三类,不显示次数;没有类别时使用思考标题。运行中的组头展示实时工具类别,没有运行工具时使用思考标题;标准模式附加实时详情。实时标题至少保留 150ms,只保留最新待显示标题。 @@ -181,7 +187,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" 英文组合标题将第一项之后各项的首字母转为小写。组结束时立即切换完成摘要;150ms 最短保留时间针对运行中标题变化,不延迟完成状态。详细模式隐藏运行中轮次的组头,包括被回复或 steering 截断、但所属轮次尚未结束的小组;完全展开模式也隐藏历史组头。 -组头显示类别图标,悬停或键盘聚焦时替换为向下箭头,展开时显示向上箭头。手动展开的组正文使用 6px 行间距、`min(400px, 50vh)` 限高及 24px 方向渐隐;到达边缘时滚轮可以继续滚动外层文本记录。详细模式对运行中轮次取消组级限高;完全展开模式对历史轮次也采用该布局。两种模式下过程行均保留 6px 间距,Assistant 回复周围保留 12px 间距。组头与过程行同高。展开组头与内容相隔 8px,整轮过程控件周围保留 16px 间距。 +组头显示类别图标,悬停或键盘聚焦时替换为向下箭头,展开时显示向上箭头。手动展开的组正文使用 6px 行间距、`min(400px, 50vh)` 限高及 24px 方向渐隐;到达边缘时滚轮可以继续滚动外层文本记录。详细模式对运行中轮次取消组级限高;完全展开模式对历史轮次也采用该布局。两种模式下过程行均保留 6px 间距,Assistant 回复周围保留 12px 间距。组头与过程行同高。展开组头与内容相隔 8px,整轮过程控件与其输入、以及紧随其后的可见行之间保留 16px 间距;与控件之间隔着已收起行的回复保持 12px 回复间距,因此折叠不会改变它的间距。 已展开的限高组仅在自己的滚动位置处于底部时跟随内容增长。滚离底部暂停该组跟随,滚回底部后恢复,与外层文本记录的跟随状态独立。手动展开未关闭的组时定位到底部并跟随增长;手动展开已关闭的组时从顶部开始且不启用跟随,即使初始内容无需滚动即可容纳。数据中的组变为已关闭,或通过模式切换恢复限高时,不重置已展开组的阅读位置。浏览器查找沿用自身的展开定位。 diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-process-presentation.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-process-presentation.ts index 11b7969618..69a740cd46 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-process-presentation.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-process-presentation.ts @@ -20,8 +20,8 @@ function samePresentation( && left.turnStarted === right.turnStarted && left.turnClosed === right.turnClosed && left.hasExternalProcess === right.hasExternalProcess - && left.hasInterleavedInput === right.hasInterleavedInput - && left.compactAnswer === right.compactAnswer) + && left.hasNonReplyProcess === right.hasNonReplyProcess + && left.hasInterleavedInput === right.hasInterleavedInput) } function derivePresentation( @@ -48,21 +48,25 @@ function derivePresentation( } let hasExternalProcess = false + let hasNonReplyProcess = false let hasInterleavedInput = false - let compactAnswer = true for (const key of keys) { const node = nodes.get(key) as ChatNode | undefined if (node === undefined || !isVisibleChatNode(node) || node.kind === 'turn-process') continue if (isAtOrOutside(node, 'input') && (openingHumanAnchor === undefined || node.anchorSeq > openingHumanAnchor)) { hasInterleavedInput = true - if (spec.answerAnchorSeq === null || node.anchorSeq < spec.answerAnchorSeq) compactAnswer = false } if (isAtOrOutside(node, 'turn') || node.anchorSeq < spec.processStartSeq || (spec.answerAnchorSeq !== null && node.anchorSeq >= spec.answerAnchorSeq)) continue if (node.kind !== 'assistant-step' || spec.answerStep === null || node.data.step !== spec.answerStep) { hasExternalProcess = true + // When every reply stays visible, another Step's reply folds only its reasoning. + if (node.kind !== 'assistant-step' + || node.data.blocks.some(block => block.kind === 'reasoning' && block.text.trim() !== '')) { + hasNonReplyProcess = true + } } } return { @@ -71,8 +75,8 @@ function derivePresentation( turnStarted: location.turn.start !== undefined, turnClosed: location.turn.status === 'closed', hasExternalProcess, + hasNonReplyProcess, hasInterleavedInput, - compactAnswer, } } diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index 48cfcc8e13..7a950132e2 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -106,6 +106,10 @@ export const zh = { 'settings.collapse.description': '选择何时自动收起工作步骤', 'settings.collapse.completion': '回答结束后', 'settings.collapse.nextInput': '下次有新消息时', + 'settings.foldedReplies.title': '收起后显示的回复', + 'settings.foldedReplies.description': '工作步骤收起后,保留哪些助手回复', + 'settings.foldedReplies.all': '全部回复', + 'settings.foldedReplies.final': '仅最终回答', 'settings.transcript.compact': '简洁', 'settings.transcript.standard': '标准', 'settings.transcript.detailed': '详细', @@ -304,6 +308,10 @@ export const en = { 'settings.collapse.description': 'Choose when to automatically collapse work details', 'settings.collapse.completion': 'On completion', 'settings.collapse.nextInput': 'On next message', + 'settings.foldedReplies.title': 'Replies After Collapse', + 'settings.foldedReplies.description': 'Choose which assistant replies stay visible when work details collapse', + 'settings.foldedReplies.all': 'All replies', + 'settings.foldedReplies.final': 'Final answer only', 'settings.transcript.compact': 'Compact', 'settings.transcript.standard': 'Standard', 'settings.transcript.detailed': 'Detailed', diff --git a/packages/client/ui-chat/src/client/presentation-policy.ts b/packages/client/ui-chat/src/client/presentation-policy.ts index 0bb38c7cdd..e1ff9ac042 100644 --- a/packages/client/ui-chat/src/client/presentation-policy.ts +++ b/packages/client/ui-chat/src/client/presentation-policy.ts @@ -1,8 +1,8 @@ -/** Chat presentation derived from Host-backed detail and collapse timing. */ +/** Chat presentation derived from Host-backed detail, collapse timing, and folded replies. */ import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' -import type { CollapseTiming, TranscriptViewMode } from '../chat-settings.ts' -export { COLLAPSE_TIMINGS, type CollapseTiming } from '../chat-settings.ts' +import { DEFAULT_FOLDED_REPLIES, type CollapseTiming, type FoldedReplies, type TranscriptViewMode } from '../chat-settings.ts' +export { COLLAPSE_TIMINGS, FOLDED_REPLIES, type CollapseTiming, type FoldedReplies } from '../chat-settings.ts' /** Presentation capabilities that one work-details mode enables. */ export interface ChatPresentationPolicy { @@ -10,6 +10,8 @@ export interface ChatPresentationPolicy { readonly mode: TranscriptViewMode /** Saved collapse timing; completion retains the default immediate folding and scrolling. */ readonly collapseTiming: CollapseTiming + /** Assistant replies that stay visible while a completed Turn folds: every reply, or only the final answer. */ + readonly foldedReplies: FoldedReplies /** Whether a normally completed Turn folds its process rows behind the whole-Turn control. */ readonly foldCompletedTurns: boolean /** Collapsible group headers for all Turns, historical Turns only, or no Turns. */ @@ -20,7 +22,7 @@ export interface ChatPresentationPolicy { readonly settledReasoningPreview: boolean } -const POLICIES: Readonly>> = { +const POLICIES: Readonly>> = { compact: { mode: 'compact', foldCompletedTurns: true, @@ -51,49 +53,57 @@ const POLICIES: Readonly> { +function withTiming( + collapseTiming: CollapseTiming, + foldedReplies: FoldedReplies, +): Readonly> { return { - compact: { ...POLICIES.compact, collapseTiming }, - standard: { ...POLICIES.standard, collapseTiming }, - detailed: { ...POLICIES.detailed, collapseTiming }, - verbose: { ...POLICIES.verbose, collapseTiming }, + compact: { ...POLICIES.compact, collapseTiming, foldedReplies }, + standard: { ...POLICIES.standard, collapseTiming, foldedReplies }, + detailed: { ...POLICIES.detailed, collapseTiming, foldedReplies }, + verbose: { ...POLICIES.verbose, collapseTiming, foldedReplies }, } } const TIMING_POLICIES = { - completion: withTiming('completion'), - 'next-input': withTiming('next-input'), + completion: { all: withTiming('completion', 'all'), final: withTiming('completion', 'final') }, + 'next-input': { all: withTiming('next-input', 'all'), final: withTiming('next-input', 'final') }, } /** - * Resolve a stable policy for one detail mode and collapse timing. + * Resolve a stable policy for one detail mode, collapse timing, and folded-reply choice. * @param mode - persisted work-details mode. * @param collapseTiming - persisted timing; defaults to immediate completion folding. - * @returns the same policy object for the same pair of choices. + * @param foldedReplies - persisted folded-reply choice; defaults to keeping every reply visible. + * @returns the same policy object for the same choices. */ export function presentationPolicyFor( mode: TranscriptViewMode, collapseTiming: CollapseTiming = 'completion', + foldedReplies: FoldedReplies = DEFAULT_FOLDED_REPLIES, ): ChatPresentationPolicy { - return TIMING_POLICIES[collapseTiming][mode] + return TIMING_POLICIES[collapseTiming][foldedReplies][mode] } /** - * Combine Host-backed detail and persisted timing without owning subscriptions. + * Combine Host-backed detail, persisted timing, and folded replies without owning subscriptions. * @param mode - live work-details mode. * @param collapseTiming - live Host-backed collapse timing. - * @returns observable policy whose subscriptions follow both preferences. + * @param foldedReplies - live Host-backed folded-reply choice. + * @returns observable policy whose subscriptions follow all three preferences. */ export function derivePresentationPolicy( mode: ObservableSnapshot, collapseTiming: ObservableSnapshot, + foldedReplies: ObservableSnapshot, ): ObservableSnapshot { return { - getSnapshot: () => presentationPolicyFor(mode.getSnapshot(), collapseTiming.getSnapshot()), + getSnapshot: () => presentationPolicyFor(mode.getSnapshot(), collapseTiming.getSnapshot(), foldedReplies.getSnapshot()), subscribe: (listener) => { const unsubscribeMode = mode.subscribe(listener) const unsubscribeTiming = collapseTiming.subscribe(listener) - return () => { unsubscribeMode(); unsubscribeTiming() } + const unsubscribeReplies = foldedReplies.subscribe(listener) + return () => { unsubscribeMode(); unsubscribeTiming(); unsubscribeReplies() } }, } } diff --git a/packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx b/packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx index d438e25c1d..ee0d5de836 100644 --- a/packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx +++ b/packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx @@ -4,7 +4,7 @@ import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { TRANSCRIPT_VIEW_MODES, type TranscriptViewMode } from '../../chat-settings.ts' import type { ChatKey } from '../locale.ts' -import { COLLAPSE_TIMINGS, type CollapseTiming } from '../presentation-policy.ts' +import { COLLAPSE_TIMINGS, FOLDED_REPLIES, type CollapseTiming, type FoldedReplies } from '../presentation-policy.ts' import { PreferenceRow } from './PreferenceRow.tsx' /** Registration-side work-details preference face. */ @@ -14,11 +14,15 @@ export interface TranscriptViewRowInjected { transcriptView: SnapshotStore /** Persisted collapse timing bound as useCollapseTiming. */ collapseTiming: SnapshotStore + /** Persisted folded-reply choice bound as useFoldedReplies. */ + foldedReplies: SnapshotStore } /** Change the work-details presentation. */ setTranscriptView: (mode: TranscriptViewMode) => void /** Persist collapse timing independently of the work-details mode. */ setCollapseTiming: (timing: CollapseTiming) => void + /** Persist which Assistant replies stay visible in a folded Turn. */ + setFoldedReplies: (replies: FoldedReplies) => void } /** Full Settings-row props. */ @@ -39,16 +43,23 @@ const COLLAPSE_LABELS = { 'next-input': 'settings.collapse.nextInput', } as const satisfies Record +const FOLDED_REPLY_LABELS = { + all: 'settings.foldedReplies.all', + final: 'settings.foldedReplies.final', +} as const satisfies Record + /** - * Render the independent, Host-backed work-details and collapse-timing selectors. + * Render the independent, Host-backed work-details, collapse-timing, and folded-reply selectors. + * Verbose never folds a Turn, so it omits the folded-reply selector. * @param props - composed Settings slot props. * @returns the preference row. */ export function TranscriptViewRow({ - useTranscriptView, setTranscriptView, useCollapseTiming, setCollapseTiming, t, + useTranscriptView, setTranscriptView, useCollapseTiming, setCollapseTiming, useFoldedReplies, setFoldedReplies, t, }: TranscriptViewRowProps) { const mode = useTranscriptView(value => value) const timing = useCollapseTiming(value => value) + const replies = useFoldedReplies(value => value) return ( <> ({ id, label: t(COLLAPSE_LABELS[id]) }))} onSelect={(value) => { setCollapseTiming(value as CollapseTiming) }} /> + {mode !== 'verbose' && ( + ({ id, label: t(FOLDED_REPLY_LABELS[id]) }))} + onSelect={(value) => { setFoldedReplies(value as FoldedReplies) }} + /> + )} ) } diff --git a/packages/client/ui-chat/src/client/transcript-view.ts b/packages/client/ui-chat/src/client/transcript-view.ts index 83928e2e8f..66b8b7a53b 100644 --- a/packages/client/ui-chat/src/client/transcript-view.ts +++ b/packages/client/ui-chat/src/client/transcript-view.ts @@ -2,9 +2,9 @@ import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { ConfigForm } from '@deepseek-ai/dsh-client-ui-settings/client' -import type { CollapseTiming } from './presentation-policy.ts' +import type { CollapseTiming, FoldedReplies } from './presentation-policy.ts' import { - COLLAPSE_TIMING_FIELD, DEFAULT_COLLAPSE_TIMING, + COLLAPSE_TIMING_FIELD, DEFAULT_COLLAPSE_TIMING, DEFAULT_FOLDED_REPLIES, FOLDED_REPLIES_FIELD, DEFAULT_TRANSCRIPT_VIEW_MODE, LEGACY_TRANSCRIPT_VIEW_MODE, LEGACY_EXPANDED_TRANSCRIPT_VIEW_MODE, TRANSCRIPT_VIEW_FIELD, type ChatSettings, type TranscriptViewMode, } from '../chat-settings.ts' @@ -16,6 +16,8 @@ export class TranscriptViewPolicy { readonly mode: SnapshotStore /** Reactive collapse timing, defaulting to completion until Host settings arrive. */ readonly collapseTiming: SnapshotStore = createSnapshotStore(DEFAULT_COLLAPSE_TIMING) + /** Reactive folded-reply choice, keeping every reply until Host settings arrive. */ + readonly foldedReplies: SnapshotStore = createSnapshotStore(DEFAULT_FOLDED_REPLIES) /** * @param host - durable Chat settings scope. @@ -53,6 +55,16 @@ export class TranscriptViewPolicy { void this.host.set(COLLAPSE_TIMING_FIELD, timing) } + /** + * Publish and persist which Assistant replies stay visible in a folded Turn. + * @param replies - every reply, or only the final answer. + */ + setFoldedReplies(replies: FoldedReplies): void { + if (this.foldedReplies.getSnapshot() === replies) return + this.foldedReplies.set(replies) + void this.host.set(FOLDED_REPLIES_FIELD, replies) + } + /** Adopt the latest accepted Host section without writing it back. */ private adopt(): void { const section = this.host.getSnapshot().value @@ -63,5 +75,7 @@ export class TranscriptViewPolicy { if (this.mode.getSnapshot() !== mode) this.mode.set(mode) const timing = section.collapseTiming ?? DEFAULT_COLLAPSE_TIMING if (this.collapseTiming.getSnapshot() !== timing) this.collapseTiming.set(timing) + const replies = section.foldedReplies ?? DEFAULT_FOLDED_REPLIES + if (this.foldedReplies.getSnapshot() !== replies) this.foldedReplies.set(replies) } } diff --git a/packages/client/ui-chat/src/index.ts b/packages/client/ui-chat/src/index.ts index e5876b8392..771d147a0c 100644 --- a/packages/client/ui-chat/src/index.ts +++ b/packages/client/ui-chat/src/index.ts @@ -2,7 +2,7 @@ import type {} from '@deepseek-ai/dsh-settings' import type { Volatile, Context } from '@deepseek-ai/cordis' -import type { ChatSettings, CollapseTiming, LinkOpening, PerformanceUsageMode } from './chat-settings.ts' +import type { ChatSettings, CollapseTiming, FoldedReplies, LinkOpening, PerformanceUsageMode } from './chat-settings.ts' import z from '@deepseek-ai/schemastery' import { TRANSCRIPT_VIEW_FIELD } from './chat-settings.ts' @@ -20,6 +20,8 @@ export interface Config { transcriptView: Volatile /** When completed work details return to their historical presentation. */ collapseTiming: Volatile + /** Assistant replies kept visible while a completed Turn folds its process. */ + foldedReplies: Volatile /** Performance and usage detail level. */ performanceUsage: Volatile /** Default destination for Chat HTTP(S) links. */ @@ -30,6 +32,7 @@ export interface Config { export const Config = z.object({ [TRANSCRIPT_VIEW_FIELD]: ChatSettingsFields[TRANSCRIPT_VIEW_FIELD].volatile(), collapseTiming: ChatSettingsFields.collapseTiming.volatile(), + foldedReplies: ChatSettingsFields.foldedReplies.volatile(), performanceUsage: ChatSettingsFields['performanceUsage'].volatile(), linkOpening: ChatSettingsFields.linkOpening.volatile(), }) diff --git a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx index 2898fab578..836a065d7e 100644 --- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx @@ -155,6 +155,11 @@ describe('Chat inject API', () => { setCollapseTiming('completion') expect(presentation.getSnapshot()).toMatchObject({ mode: 'standard', collapseTiming: 'completion' }) expect(b.chatSettings.set).toHaveBeenLastCalledWith('collapseTiming', 'completion') + const setFoldedReplies = preference.setFoldedReplies as TranscriptViewRowInjected['setFoldedReplies'] + expect(presentation.getSnapshot()).toMatchObject({ foldedReplies: 'all' }) + setFoldedReplies('final') + expect(presentation.getSnapshot()).toMatchObject({ mode: 'standard', collapseTiming: 'completion', foldedReplies: 'final' }) + expect(b.chatSettings.set).toHaveBeenLastCalledWith('foldedReplies', 'final') expect(b.session.prompt).not.toHaveBeenCalled() expect(b.session.cancel).not.toHaveBeenCalled() } finally { diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 373fe6cf07..e7db8eefb4 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -34,7 +34,7 @@ import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { PartialArguments } from '@deepseek-ai/dsh-util-values' import { createChatStore } from '../src/client/stores.ts' -import { derivePresentationPolicy, type CollapseTiming } from '../src/client/presentation-policy.ts' +import { derivePresentationPolicy, type CollapseTiming, type FoldedReplies } from '../src/client/presentation-policy.ts' import { CHAT_FLOW_INJECT, CHAT_NODE_INJECT } from '../src/client/apply.ts' import { ChatView } from '../src/client/chat/ChatView.tsx' import { ChatFlow } from '../src/client/chat/ChatFlow.tsx' @@ -417,6 +417,7 @@ function makeHarness( const chat = createChatStore().create() const transcriptView = createSnapshotStore('compact') const collapseTiming = createSnapshotStore('completion') + const foldedReplies = createSnapshotStore('all') const performanceUsage = createSnapshotStore<'compact' | 'detailed'>('detailed') const t = makeTranslate(zh, commonZh) const toolOwners: ToolOwner[] = [] @@ -497,7 +498,7 @@ function makeHarness( }, useStore: bindSnapshotSelector(chat), actions: chat.actions, - usePresentation: bindSnapshotSelector(derivePresentationPolicy(transcriptView, collapseTiming)), + usePresentation: bindSnapshotSelector(derivePresentationPolicy(transcriptView, collapseTiming, foldedReplies)), // The fixture implements the concrete flow dispatch behind the generic slot signature. renderSlot: renderSlot as ChatViewSlotProps['renderSlot'], SessionProvider: SessionProviderStub, @@ -546,6 +547,7 @@ function makeHarness( }, setTranscriptView: (mode: TranscriptViewMode) => { transcriptView.set(mode) }, setCollapseTiming: (timing: CollapseTiming) => { collapseTiming.set(timing) }, + setFoldedReplies: (replies: FoldedReplies) => { foldedReplies.set(replies) }, setNodeRenderer: (renderer: NodeRenderer) => { nodeSlotOverride = renderer }, setImageRenderer: (renderer: ImageRenderer) => { imageSlotOverride = renderer }, } @@ -2487,7 +2489,87 @@ describe('ChatView', () => { expect(branchButtons.map(button => button.getAttribute('aria-disabled'))).toEqual([null, null]) }) - it('folds Think and Tool rows before the final answer without unmounting them', () => { + it('keeps every Assistant reply visible while the whole Turn folds its process', () => { + const progress = { + ...assistant(2, 'stage reply', 1, 1), + blocks: [ + { kind: 'reasoning' as const, text: 'inspect the repository' }, + { kind: 'text' as const, text: 'stage reply' }, + ], + } + const h = makeHarness({ + nodes: [ + user(1, 'question'), + progress, + toolResult(3, 'a'), + assistant(4, 'final answer', 1, 2), + user(5, 'next question'), + assistant(6, 'reply before the last step', 2, 1), + toolResult(7, 'b'), + reasoningAssistant(8, 'closing thought', 2, 2), + user(10, 'third question'), + assistant(11, 'first notice reply', 3, 1), + assistant(12, 'second notice reply', 3, 2), + ], + turnEnds: new Map([[1, 5], [2, 9], [3, 13]]), + }) + const view = render() + const visible = (text: string) => view.getByText(text).closest('[hidden]') === null + + expect(visible('stage reply')).toBe(true) + expect(visible('final answer')).toBe(true) + expect(visible('reply before the last step')).toBe(true) + expect(visible('first notice reply')).toBe(true) + expect(visible('inspect the repository')).toBe(false) + expect(visible('closing thought')).toBe(false) + const members = [...view.container.querySelectorAll('[data-turn-process-member]')] + expect(members.map(member => member.dataset.chatFlowKind)).toEqual(['tool-call', 'tool-call', 'assistant-step']) + expect(members.every(member => member.getAttribute('hidden') === 'until-found')).toBe(true) + const [first, second, third] = view.container.querySelectorAll('[data-turn-process]') + expect(first?.getAttribute('aria-expanded')).toBe('false') + expect(second?.getAttribute('aria-expanded')).toBe('false') + // Replies alone leave nothing to fold. + expect(third?.disabled).toBe(true) + + fireEvent.click(first!) + expect(visible('inspect the repository')).toBe(true) + }) + + it('switches between keeping every reply and only the final answer without remounting rows', () => { + const h = makeHarness({ + nodes: [ + user(1, 'question'), + reasoningAssistant(2, 'inspect the repository', 1, 1), + assistant(3, 'stage reply', 1, 2), + toolResult(4, 'a'), + assistant(5, 'final answer', 1, 3), + user(7, 'next question'), + assistant(8, 'first notice reply', 2, 1), + assistant(9, 'second notice reply', 2, 2), + ], + turnEnds: new Map([[1, 6], [2, 10]]), + }) + const view = render() + const stage = view.getByText('stage reply').closest('[data-chat-flow-kind]')! + const notice = view.getByText('first notice reply').closest('[data-chat-flow-kind]')! + const [, second] = view.container.querySelectorAll('[data-turn-process]') + expect(stage.closest('[hidden]')).toBeNull() + expect(second?.disabled).toBe(true) + + act(() => { h.setFoldedReplies('final') }) + expect(view.getByText('stage reply').closest('[data-chat-flow-kind]')).toBe(stage) + expect(stage.getAttribute('hidden')).toBe('until-found') + expect(notice.getAttribute('hidden')).toBe('until-found') + expect(view.getByText('final answer').closest('[hidden]')).toBeNull() + expect(view.getByText('second notice reply').closest('[hidden]')).toBeNull() + expect(second?.disabled).toBe(false) + + act(() => { h.setFoldedReplies('all') }) + expect(stage.getAttribute('hidden')).toBeNull() + expect(notice.getAttribute('hidden')).toBeNull() + }) + + it('folds Think and Tool rows around visible replies without unmounting them', () => { const first = { ...assistant(2, 'earlier reply', 1, 1), blocks: [ @@ -2513,35 +2595,36 @@ describe('ChatView', () => { expect(toggle.getAttribute('data-turn-process-tool-calls')).toBe('1') expect(toggle.getAttribute('data-turn-process-messages')).toBe('1') expect(toggle.getAttribute('data-turn-process-subagents')).toBe('1') + const reasoning = () => view.getByText('inspect the repository').closest('[hidden]') const members = [...view.container.querySelectorAll('[data-turn-process-member]')] - expect(members).toHaveLength(3) - expect(members.map(member => member.getAttribute('hidden'))) - .toEqual(['until-found', 'until-found', 'until-found']) - expect(members[0]?.textContent).toContain('inspect the repository') - expect(members[1]?.textContent).toContain('bash:a') - expect(members[2]?.textContent).toContain('subagent:b') - expect(view.getByText('final answer')).toBeTruthy() + expect(members).toHaveLength(2) + expect(members.map(member => member.getAttribute('hidden'))).toEqual(['until-found', 'until-found']) + expect(members[0]?.textContent).toContain('bash:a') + expect(members[1]?.textContent).toContain('subagent:b') + expect(view.getByText('earlier reply').closest('[hidden]')).toBeNull() + expect(view.getByText('final answer').closest('[hidden]')).toBeNull() + expect(reasoning()).not.toBeNull() fireEvent.click(toggle) expect(toggle.getAttribute('aria-expanded')).toBe('true') - expect(members.map(member => member.getAttribute('hidden'))).toEqual([null, null, null]) + expect(members.map(member => member.getAttribute('hidden'))).toEqual([null, null]) + expect(reasoning()).toBeNull() fireEvent.click(toggle) - expect(members.map(member => member.getAttribute('hidden'))) - .toEqual(['until-found', 'until-found', 'until-found']) - fireEvent(members[1]!, new Event('beforematch')) + expect(members.map(member => member.getAttribute('hidden'))).toEqual(['until-found', 'until-found']) + fireEvent(members[0]!, new Event('beforematch')) expect(toggle.getAttribute('aria-expanded')).toBe('true') - expect(members.map(member => member.getAttribute('hidden'))).toEqual([null, null, null]) + expect(members.map(member => member.getAttribute('hidden'))).toEqual([null, null]) act(() => { h.set({ nodes: [user(1, 'question'), first] }) }) expect(view.getByRole('button', { name: '已完成,用时 4秒' }).getAttribute('aria-expanded')).toBe('false') - expect(members[0]?.getAttribute('hidden')).toBeNull() + expect(view.getByText('earlier reply').closest('[hidden]')).toBeNull() act(() => { h.set({ nodes: [user(1, 'question'), first, toolResult(3, 'a'), toolResult(4, 'b', 'subagent'), second], }) }) const renewedToggle = view.getByRole('button', { name: '已完成,用时 4秒' }) expect(renewedToggle.getAttribute('aria-expanded')).toBe('true') - expect(members[0]?.getAttribute('hidden')).toBeNull() + expect(reasoning()).toBeNull() }) it('omits injected Context while folding and revealing the visible Turn process', () => { @@ -2657,23 +2740,6 @@ describe('ChatView', () => { expect(view.getByText('final answer').closest('[hidden]')).toBeNull() }) - it('keeps ordinary spacing when steering separates the process control from its answer', () => { - const h = makeHarness({ - nodes: [ - user(1, 'question'), - reasoningAssistant(2, 'inspect', 1, 1), - steering(3, 'also mention safety', 1), - assistant(4, 'final answer', 1, 2), - ], - turnEnds: new Map([[1, 5]]), - }) - const view = render() - const answer = view.container.querySelector('[data-chat-flow-kind="assistant-step"]:not([hidden])') - - expect(view.getByText('also mention safety')).toBeTruthy() - expect(answer?.hasAttribute('data-turn-process-answer')).toBe(false) - }) - it.each(['compact', 'standard', 'detailed'] as const)( 'preserves steering-separated process groups in %s mode after Turn completion', (mode) => { const nodes = [ @@ -2735,27 +2801,8 @@ describe('ChatView', () => { }, ) - it('keeps compact answer spacing after multiple opening steering inputs', () => { - const h = makeHarness({ - nodes: [ - steering(1, 'question', 1), - steering(2, 'also mention safety', 1), - reasoningAssistant(3, 'inspect', 1, 1), - assistant(4, 'final answer', 1, 2), - ], - turnEnds: new Map([[1, 5]]), - }) - const view = render() - const answer = view.container.querySelector('[data-chat-flow-kind="assistant-step"]:not([hidden])') - - expect(view.getByText('also mention safety')).toBeTruthy() - expect(answer?.hasAttribute('data-turn-process-answer')).toBe(true) - fireEvent.click(turnProcessControl(view.container)!) - expect(answer?.hasAttribute('data-turn-process-answer')).toBe(false) - }) - it('keeps a live Turn expanded and folds it once at turn/end', withClock(3_000, () => { - const process = assistant(2, 'inspect', 1, 1) + const process = reasoningAssistant(2, 'inspect', 1, 1) const h = makeHarness({ nodes: [user(1, 'question'), process], partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'streaming answer' }] }, @@ -2790,7 +2837,7 @@ describe('ChatView', () => { })) it('preserves whole-Turn folding and manual expansion across work-details modes', () => { - const process = assistant(2, 'inspect', 1, 1) + const process = reasoningAssistant(2, 'inspect', 1, 1) const h = makeHarness({ nodes: [user(1, 'question'), process, assistant(4, 'final answer', 1, 2)], turnEnds: new Map([[1, 5]]), @@ -2868,7 +2915,7 @@ describe('ChatView', () => { }) it('folds a completed Turn even while the reader is away from the tail', () => { - const first = assistant(2, 'first answer', 1, 1) + const first = reasoningAssistant(2, 'first thought', 1, 1) const h = makeHarness({ nodes: [user(1, 'question'), first], running: true, turnTimings: new Map([[1, { startTime: 0 }]]), @@ -2877,7 +2924,7 @@ describe('ChatView', () => { const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement Object.defineProperty(scroller, 'scrollHeight', { value: 1_000, writable: true }) Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true }) - const firstRow = view.getByText('first answer').closest('[data-chat-flow-kind="assistant-step"]') as HTMLElement + const firstRow = view.getByText('first thought').closest('[data-chat-flow-kind="assistant-step"]') as HTMLElement readerScroll(scroller, 100) act(() => { h.set({ @@ -2959,7 +3006,7 @@ describe('ChatView', () => { const h = makeHarness({ nodes: [ user(1, 'question'), - assistant(3, 'working', 1, 1), + reasoningAssistant(3, 'working', 1, 1), assistant(4, 'final answer', 1, 2), ], turnEnds: new Map([[1, 5]]), @@ -3112,7 +3159,7 @@ describe('ChatView', () => { h.set({ nodes: [ user(1, 'older question'), - assistant(2, 'older first answer', 1, 1), + reasoningAssistant(2, 'older first thought', 1, 1), assistant(4, 'older final answer', 1, 2), user(9, 'visible question'), assistant(10, 'visible answer', 2), @@ -3138,7 +3185,7 @@ describe('ChatView', () => { nodes: [ user(1, 'question'), context(2, 'runtime policy', 1), - assistant(3, 'working', 1, 1), + reasoningAssistant(3, 'working', 1, 1), assistant(4, 'final answer', 1, 2), ], turnEnds: new Map([[1, 5]]), @@ -3204,7 +3251,7 @@ describe('ChatView', () => { Object.defineProperty(host, 'scrollTop', { value: 0, writable: true, configurable: true }) document.body.appendChild(host) try { - const first = assistant(2, 'first answer', 1, 1) + const first = reasoningAssistant(2, 'first thought', 1, 1) const h = makeHarness({ nodes: [user(1, 'question'), first, assistant(4, 'new answer', 1, 2)], turnEnds: new Map([[1, 5]]), diff --git a/packages/client/ui-chat/tests/config.host.spec.ts b/packages/client/ui-chat/tests/config.host.spec.ts index b3ebb9b698..a1b7328c92 100644 --- a/packages/client/ui-chat/tests/config.host.spec.ts +++ b/packages/client/ui-chat/tests/config.host.spec.ts @@ -14,9 +14,13 @@ describe('ui-chat Host settings', () => { const configuration = await liveConfig(ctx, { Config, apply }) const { fiber } = configuration - expect(plainConfig(configuration.fiber.config)).toEqual({ performanceUsage: 'detailed', linkOpening: 'sidebar', collapseTiming: 'completion' }) + expect(plainConfig(configuration.fiber.config)).toEqual({ + performanceUsage: 'detailed', linkOpening: 'sidebar', collapseTiming: 'completion', foldedReplies: 'all', + }) await configuration.update({ transcriptView: 'normal' }) - expect(plainConfig(configuration.fiber.config)).toEqual({ transcriptView: 'normal', performanceUsage: 'detailed', linkOpening: 'sidebar', collapseTiming: 'completion' }) + expect(plainConfig(configuration.fiber.config)).toEqual({ + transcriptView: 'normal', performanceUsage: 'detailed', linkOpening: 'sidebar', collapseTiming: 'completion', foldedReplies: 'all', + }) for (const mode of ['expanded', 'compact', 'standard', 'detailed', 'verbose']) { await configuration.update({ transcriptView: mode }) expect(plainConfig(configuration.fiber.config)).toMatchObject({ transcriptView: mode }) @@ -50,6 +54,21 @@ describe('ui-chat Host settings', () => { await restored.fiber.dispose() }) + it('validates folded replies and restores the saved choice', async () => { + const ctx = new Context() + const configuration = await liveConfig(ctx, { Config, apply }) + expect(plainConfig(configuration.fiber.config)).toMatchObject({ foldedReplies: 'all' }) + await configuration.update({ foldedReplies: 'final' }) + const saved = plainConfig(configuration.fiber.config) + if (typeof saved !== 'object' || saved === null) throw new Error('expected saved configuration object') + expect(saved).toMatchObject({ foldedReplies: 'final' }) + await expect(configuration.update({ foldedReplies: 'none' })).rejects.toThrow() + await configuration.fiber.dispose() + const restored = await liveConfig(ctx, { Config, apply }, saved) + expect(plainConfig(restored.fiber.config)).toMatchObject({ foldedReplies: 'final' }) + await restored.fiber.dispose() + }) + it('loads without a settings provider', async () => { const ctx = new Context() await expect(ctx.plugin({ Config, apply }).await()).resolves.toBeDefined() diff --git a/packages/client/ui-chat/tests/presentation-policy.client.spec.tsx b/packages/client/ui-chat/tests/presentation-policy.client.spec.tsx index cf0cecb630..6eedcbff17 100644 --- a/packages/client/ui-chat/tests/presentation-policy.client.spec.tsx +++ b/packages/client/ui-chat/tests/presentation-policy.client.spec.tsx @@ -4,7 +4,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import type { TranscriptViewMode } from '../src/chat-settings.ts' -import { COLLAPSE_TIMINGS, derivePresentationPolicy, presentationPolicyFor, type ChatPresentationPolicy, type CollapseTiming } from '../src/client/presentation-policy.ts' +import { + COLLAPSE_TIMINGS, FOLDED_REPLIES, derivePresentationPolicy, presentationPolicyFor, + type ChatPresentationPolicy, type CollapseTiming, type FoldedReplies, +} from '../src/client/presentation-policy.ts' afterEach(cleanup) @@ -16,29 +19,36 @@ describe('Chat presentation policy', () => { ['verbose', false, 'none', true, false], ] as const)('maps %s to stable presentation capabilities', (mode, foldCompletedTurns, stepGrouping, settledReasoningPreview, liveProcessDetail) => { const policy = presentationPolicyFor(mode) - expect(policy).toEqual({ mode, collapseTiming: 'completion', foldCompletedTurns, stepGrouping, settledReasoningPreview, liveProcessDetail }) + expect(policy).toEqual({ + mode, collapseTiming: 'completion', foldedReplies: 'all', foldCompletedTurns, stepGrouping, settledReasoningPreview, liveProcessDetail, + }) expect(presentationPolicyFor(mode)).toBe(policy) expect(presentationPolicyFor(mode, 'completion')).toBe(policy) + expect(presentationPolicyFor(mode, 'completion', 'all')).toBe(policy) const deferred = presentationPolicyFor(mode, 'next-input') expect(deferred).toEqual({ ...policy, collapseTiming: 'next-input' }) expect(presentationPolicyFor(mode, 'next-input')).toBe(deferred) expect(deferred).not.toBe(policy) + const finalOnly = presentationPolicyFor(mode, 'completion', 'final') + expect(finalOnly).toEqual({ ...policy, foldedReplies: 'final' }) + expect(presentationPolicyFor(mode, 'completion', 'final')).toBe(finalOnly) }) - it('keeps all eight mode and timing policies distinct and stable', () => { + it('keeps all sixteen mode, timing, and folded-reply policies distinct and stable', () => { const policies = new Set() for (const mode of ['compact', 'standard', 'detailed', 'verbose'] as const) { for (const timing of COLLAPSE_TIMINGS) { - policies.add(presentationPolicyFor(mode, timing)) + for (const replies of FOLDED_REPLIES) policies.add(presentationPolicyFor(mode, timing, replies)) } } - expect(policies.size).toBe(8) + expect(policies.size).toBe(16) }) - it('forwards both preferences and removes both subscriptions on disposal', () => { + it('forwards all three preferences and removes their subscriptions on disposal', () => { const mode = createSnapshotStore('compact') const timing = createSnapshotStore('completion') - const policy = derivePresentationPolicy(mode, timing) + const replies = createSnapshotStore('all') + const policy = derivePresentationPolicy(mode, timing, replies) const listener = vi.fn() const dispose = policy.subscribe(listener) expect(policy.getSnapshot()).toBe(presentationPolicyFor('compact')) @@ -50,16 +60,20 @@ describe('Chat presentation policy', () => { expect(policy.getSnapshot()).toBe(presentationPolicyFor('detailed', 'next-input')) timing.set('next-input') expect(listener).toHaveBeenCalledTimes(2) + replies.set('final') + expect(listener).toHaveBeenCalledTimes(3) + expect(policy.getSnapshot()).toBe(presentationPolicyFor('detailed', 'next-input', 'final')) dispose() mode.set('verbose') timing.set('completion') - expect(listener).toHaveBeenCalledTimes(2) + replies.set('all') + expect(listener).toHaveBeenCalledTimes(3) }) it('renders only consumers whose selected field changes', () => { const mode = createSnapshotStore('compact') const timing = createSnapshotStore('completion') - const usePresentation = bindSnapshotSelector(derivePresentationPolicy(mode, timing)) + const usePresentation = bindSnapshotSelector(derivePresentationPolicy(mode, timing, createSnapshotStore('all'))) const foldRender = vi.fn() const previewRender = vi.fn() function Fold() { diff --git a/packages/client/ui-chat/tests/reasoning-render-fixture.tsx b/packages/client/ui-chat/tests/reasoning-render-fixture.tsx index 4a9d32a3ea..95d1acfe00 100644 --- a/packages/client/ui-chat/tests/reasoning-render-fixture.tsx +++ b/packages/client/ui-chat/tests/reasoning-render-fixture.tsx @@ -10,7 +10,7 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { SessionId } from '@deepseek-ai/dsh-session/types' import type { PropsRenderFactories, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { en, NS, zh } from '../src/client/locale.ts' -import { derivePresentationPolicy, type CollapseTiming } from '../src/client/presentation-policy.ts' +import { derivePresentationPolicy, type CollapseTiming, type FoldedReplies } from '../src/client/presentation-policy.ts' import { registerChatNodeRenderers } from '../src/client/chat/register-node-renderers.ts' import type { PerformanceUsageMode, TranscriptViewMode } from '../src/chat-settings.ts' @@ -44,7 +44,11 @@ export async function createReasoningRenderFixture(language = 'zh') { apply: (ctx) => { registerChatNodeRenderers(ctx, createSnapshotStore('detailed'), - derivePresentationPolicy(createSnapshotStore('detailed'), createSnapshotStore('completion'))) + derivePresentationPolicy( + createSnapshotStore('detailed'), + createSnapshotStore('completion'), + createSnapshotStore('all'), + )) }, }) await runtime.sessions.add({ id: 'reasoning-fixture' }) diff --git a/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx b/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx index 29ad9a4e4d..42bf510e98 100644 --- a/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx +++ b/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx @@ -9,7 +9,7 @@ import { zh } from '../src/client/locale.ts' import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx' import { useDetailedPresentation } from './presentation-fixture.client.ts' import type { TranscriptViewMode } from '../src/chat-settings.ts' -import { derivePresentationPolicy, type CollapseTiming } from '../src/client/presentation-policy.ts' +import { derivePresentationPolicy, type CollapseTiming, type FoldedReplies } from '../src/client/presentation-policy.ts' import { ReasoningRow } from '../src/client/chat/ReasoningRow.tsx' import { bindDisclosure, useDisclosure } from '../src/client/chat/use-disclosure.ts' import { useSearchableHidden } from '../src/client/chat/searchable-hidden.ts' @@ -106,7 +106,7 @@ describe('AssistantMarkdown reasoning blocks', () => { it('keeps non-blank rows and their expanded bodies across modes, excluding them from the response part', () => { const mode = createSnapshotStore('compact') - const usePresentation = bindSnapshotSelector(derivePresentationPolicy(mode, createSnapshotStore('completion'))) + const usePresentation = bindSnapshotSelector(derivePresentationPolicy(mode, createSnapshotStore('completion'), createSnapshotStore('all'))) const blocks: AssistantMarkdownProps['blocks'] = [ { kind: 'reasoning', text: '' }, { kind: 'reasoning', text: 'Mode thought\n\nMode body' }, @@ -176,7 +176,7 @@ describe('ReasoningRow', () => { const reset = createSnapshotStore(0) const useDisclosure = bindDisclosure(reset) const mode = createSnapshotStore('standard') - const usePresentation = bindSnapshotSelector(derivePresentationPolicy(mode, createSnapshotStore('completion'))) + const usePresentation = bindSnapshotSelector(derivePresentationPolicy(mode, createSnapshotStore('completion'), createSnapshotStore('all'))) const view = render() const root = view.container.querySelector('[data-variant="think"]')! @@ -204,7 +204,7 @@ describe('ReasoningRow', () => { it('keeps a running preview enabled in Compact and hides it on settlement without removing it', () => { const mode = createSnapshotStore('compact') - const usePresentation = bindSnapshotSelector(derivePresentationPolicy(mode, createSnapshotStore('completion'))) + const usePresentation = bindSnapshotSelector(derivePresentationPolicy(mode, createSnapshotStore('completion'), createSnapshotStore('all'))) const props = { text: 'First line\n', usePresentation, t } const view = render() const summary = view.getByText('First line') diff --git a/packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts b/packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts index 05fc6401ed..10d543defa 100644 --- a/packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts +++ b/packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts @@ -46,6 +46,30 @@ describe('TranscriptViewPolicy', () => { } }) + it('keeps every reply by default, persists an explicit folded-reply choice, and adopts saved values', () => { + const host = stubConfigForm() + const policy = new TranscriptViewPolicy(host.scope) + const changed = vi.fn() + const unsubscribe = policy.foldedReplies.subscribe(changed) + try { + expect(policy.foldedReplies.getSnapshot()).toBe('all') + policy.setFoldedReplies('all') + expect(changed).not.toHaveBeenCalled() + policy.setFoldedReplies('final') + expect(policy.foldedReplies.getSnapshot()).toBe('final') + expect(host.set).toHaveBeenCalledExactlyOnceWith('foldedReplies', 'final') + host.publish({ value: { linkOpening: 'sidebar', performanceUsage: 'detailed', foldedReplies: 'all' }, revision: 2 }) + expect(policy.foldedReplies.getSnapshot()).toBe('all') + host.publish({ value: { linkOpening: 'sidebar', performanceUsage: 'detailed' }, revision: 3 }) + expect(policy.foldedReplies.getSnapshot()).toBe('all') + expect(changed).toHaveBeenCalledTimes(2) + expect(host.set).toHaveBeenCalledTimes(1) + } finally { + unsubscribe() + policy.dispose() + } + }) + it('restores both saved preferences in each new instance', () => { const host = stubConfigForm() host.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'standard', performanceUsage: 'detailed' }, revision: 1, writable: true }) 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 b8f3b5abd0..f67f18eb92 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 @@ -12,7 +12,7 @@ import { PerformanceUsageRow } from '../src/client/settings/PerformanceUsageRow. import type { LinkOpening, PerformanceUsageMode, TranscriptViewMode } from '../src/chat-settings.ts' import { LinkOpeningRow } from '../src/client/settings/LinkOpeningRow.tsx' import { en, zh } from '../src/client/locale.ts' -import type { CollapseTiming } from '../src/client/presentation-policy.ts' +import type { CollapseTiming, FoldedReplies } from '../src/client/presentation-policy.ts' afterEach(cleanup) @@ -38,8 +38,10 @@ const useResource = (() => ({ status: 'none' as const, value: undefined, failure function mount(mode: TranscriptViewMode = 'standard', dictionary: typeof en | typeof zh = en) { const source = createSnapshotStore(mode) const collapseTiming = createSnapshotStore('completion') + const foldedReplies = createSnapshotStore('all') const setTranscriptView = vi.fn((next: TranscriptViewMode) => { source.set(next) }) const setCollapseTiming = vi.fn((next: CollapseTiming) => { collapseTiming.set(next) }) + const setFoldedReplies = vi.fn((next: FoldedReplies) => { foldedReplies.set(next) }) const props: TranscriptViewRowProps = { usePanelInfo: selector => selector({ activePanelId: null }), useSessions: emptySessions(), @@ -50,12 +52,14 @@ function mount(mode: TranscriptViewMode = 'standard', dictionary: typeof en | ty ...allowAllAccess, useTranscriptView: bindSnapshotSelector(source), useCollapseTiming: bindSnapshotSelector(collapseTiming), + useFoldedReplies: bindSnapshotSelector(foldedReplies), setTranscriptView, setCollapseTiming, + setFoldedReplies, t: makeTranslate(dictionary), } render() - return { setTranscriptView, setCollapseTiming, collapseTiming, props } + return { setTranscriptView, setCollapseTiming, setFoldedReplies, collapseTiming, foldedReplies, props } } describe('TranscriptViewRow', () => { @@ -75,7 +79,7 @@ describe('TranscriptViewRow', () => { const titleElement = screen.getByText(title) expect(screen.getByText(detailTitle).compareDocumentPosition(titleElement) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0) expect(screen.getByText(description)).toBeDefined() - expect(screen.getAllByRole('button')).toHaveLength(2) + expect(screen.getAllByRole('button')).toHaveLength(3) const trigger = screen.getByRole('button', { name: completion }) expect(trigger.getAttribute('aria-expanded')).toBe('false') fireEvent.click(trigger) @@ -91,6 +95,36 @@ describe('TranscriptViewRow', () => { expect(b.setTranscriptView).not.toHaveBeenCalled() }) + it.each([ + [en, 'Replies After Collapse', 'Choose which assistant replies stay visible when work details collapse', 'All replies', 'Final answer only'], + [zh, '收起后显示的回复', '工作步骤收起后,保留哪些助手回复', '全部回复', '仅最终回答'], + ] as const)('selects which replies stay visible below collapse timing (%s)', (dictionary, title, description, all, final) => { + const b = mount('standard', dictionary) + const timingTitle = dictionary === en ? 'When to Collapse Work Details' : '工作步骤收起时机' + expect(screen.getByText(timingTitle).compareDocumentPosition(screen.getByText(title)) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0) + expect(screen.getByText(description)).toBeDefined() + fireEvent.click(screen.getByRole('button', { name: all })) + expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([all, final]) + fireEvent.click(screen.getByRole('menuitem', { name: final })) + expect(b.setFoldedReplies).toHaveBeenLastCalledWith('final') + expect(b.foldedReplies.getSnapshot()).toBe('final') + fireEvent.click(screen.getByRole('button', { name: final })) + fireEvent.click(screen.getByRole('menuitem', { name: all })) + expect(b.setFoldedReplies).toHaveBeenLastCalledWith('all') + expect(b.setCollapseTiming).not.toHaveBeenCalled() + expect(b.setTranscriptView).not.toHaveBeenCalled() + }) + + it('omits the folded-reply choice while Verbose never folds a Turn', () => { + const b = mount('verbose') + expect(screen.queryByText('Replies After Collapse')).toBeNull() + expect(screen.getAllByRole('button')).toHaveLength(2) + fireEvent.click(screen.getByRole('button', { name: 'Verbose' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Standard' })) + expect(b.setTranscriptView).toHaveBeenLastCalledWith('standard') + expect(screen.getByText('Replies After Collapse')).toBeDefined() + }) + it('follows timing changes and closes its menu without changing either preference', () => { const b = mount() act(() => { b.collapseTiming.set('next-input') }) diff --git a/packages/experimental/cua-macos/README.i18n.yaml b/packages/experimental/cua-macos/README.i18n.yaml index 477e2b7958..69c1f545a8 100644 --- a/packages/experimental/cua-macos/README.i18n.yaml +++ b/packages/experimental/cua-macos/README.i18n.yaml @@ -15,8 +15,8 @@ en: 867d9ec544719acf zh: 92348599582dc33a /deepseek-ai-dsh-experimental-cua-macos/use-this-package: - en: f2ceaaa0c834a931 - zh: 0edd75ea5323d07f + en: 5f070508770a1a48 + zh: 07d6e582c01b0ea3 /deepseek-ai-dsh-experimental-cua-macos/use-this-package/connect-an-external-execution-environment: en: e7909c9b43e44fbc zh: 98949877d4ea3a86 diff --git a/packages/experimental/cua-macos/README.md b/packages/experimental/cua-macos/README.md index ed56375c26..3909b8f97a 100644 --- a/packages/experimental/cua-macos/README.md +++ b/packages/experimental/cua-macos/README.md @@ -54,6 +54,7 @@ The dump command checks profile composition without a model request. For a real | `pasteReadTimeoutMs` | `1000` | Clipboard read deadline after the Paste command returns; expiry reports `PASTE_NOT_CONSUMED`. | | `maxAppMatches` | `20` | Maximum installed-app search matches; broader queries reject and require a narrower name. | | `maxElements` / `maxDepth` | `2000` / `25` | Accessibility traversal limits. | +| `maxClickHoldMs` | `5000` | Maximum explicit click hold in milliseconds; accepts nonnegative integers. Zero only permits immediate release. | | `dragDurationMs` / `dragSteps` | `400` / `20` | Duration and movement segments for a drag. | | `textEventIntervalMs` | `0.25` | Delay after each Unicode down/up event, from 0 to 1000 ms. Chord timing is independent. | @@ -179,7 +180,7 @@ App and Window methods: - getScreenshot(options?: { emit?: boolean }) -> CuaScreenshot - getAXStateAndScreenshot(options?) -> { state: string, screenshot: CuaScreenshot } - hover(target: number | [x, y], options?: { relative?: boolean }) -> { dispatched, verified, hoverRoute, systemPointerMoved } -- click(target: number | [x, y], options?: { mouseButton?: "left" | "right" | "middle", clickCount?: 1 | 2, modifiers?: Array<"command" | "control" | "option" | "shift" | "fn">, relative?: boolean }) -> action result +- click(target: number | [x, y], options?: { mouseButton?: "left" | "right" | "middle", clickCount?: 1 | 2, holdMs?: number, modifiers?: Array<"command" | "control" | "option" | "shift" | "fn">, relative?: boolean }) -> action result - setValue(elementIndex: number, value: string) -> action result - selectText(elementIndex: number, text: string, options?: { prefix?: string, suffix?: string, selectionType?: "text" | "cursor_before" | "cursor_after" }) -> action result - performSecondaryAction(elementIndex: number, action: string) -> action result @@ -192,7 +193,7 @@ Await operations sequentially. After input, read fresh state to verify the effec Hover first uses advertised AX alternate UI without moving the system cursor, preserving revealed descendants until the pointer target leaves their region. When unavailable, it moves the visible system cursor without pressing a button; that route requires the target point to be unobscured in the foreground app. FOREGROUND_REQUIRED requires explicit activation or raising and a fresh observation before retrying. Returning, AX reads and screenshots do not restore the previous point. Wait for the application's hover delay, observe, then hover into the revealed controls before clicking. Use successive hover calls for intermediate points. The result reports hoverRoute and systemPointerMoved. Other input or application changes can interrupt the interaction; app objects do not reserve applications between calls. -Use selectText only on controls advertising selectionWritable. It selects a unique literal occurrence, or places the cursor before/after it; prefix and suffix must be immediately adjacent. AMBIGUOUS_TEXT requires more context; TEXT_NOT_FOUND or SELECTION_CHANGED requires a fresh observation before deciding on input. Left clicks support single/double clicks; right and middle clicks support single clicks. Modified clicks use pointer input. Keypad names such as KP_0 and KP_Enter retain their physical key identity. Window objects expose observation and pointer/AX actions; keyboard input remains app-scoped and follows current focus. Scroll deltas are integer lines from -10 to 10; positive values reveal content above or left. Paste delivers plain text or HTML through the application's enabled standard Paste menu command. Paste follows the target app's focused text input. Foreground requirements vary by application; for example, ChatGPT/Codex was observed to accept background Paste without reading clipboard data. PASTE_NOT_CONSUMED means the command was dispatched but no clipboard read was observed before the configured deadline. Inspect the target contents, explicitly activate the app and focus the intended input, then observe again before deciding whether to retry; a delayed paste may still run. Original clipboard formats are restored if the clipboard still belongs to this paste; a later copy is preserved. Unsupported Paste commands fail without typing fallback. CLIPBOARD_CHANGED means another writer intervened; observe before deciding on further input. Browser applications can be controlled with getApp like other native apps; browser tabs are unavailable. Installed-app search covers standard macOS application directories; use an absolute application path for another location. Launch does not restore a window or document, and cancellation may leave an application already started. The execution host must install the object API and own variable persistence; this plugin does not preload a cua global. In persistent PTC, use the host-provided initialization snippet below once. Its installation-owned file URL works independently of the project directory. Retain app objects between calls. Observation methods emit by default and also return their value. Use void await app.getAXState() to avoid a duplicate REPL completion, or pass emit:false and print the returned text once. End screenshot calls with void await app.getScreenshot() or void await app.getAXStateAndScreenshot() so image bytes are not serialized as the program completion value. +Use selectText only on controls advertising selectionWritable. It selects a unique literal occurrence, or places the cursor before/after it; prefix and suffix must be immediately adjacent. AMBIGUOUS_TEXT requires more context; TEXT_NOT_FOUND or SELECTION_CHANGED requires a fresh observation before deciding on input. Left clicks support single/double clicks; right and middle clicks support single clicks. Modified clicks use pointer input. Optional holdMs is a nonnegative integer in milliseconds for each press, including each press of a double-click. Zero releases without an additional hold. The configured maximum is stated below. It uses pointer input even for AX targets; omission preserves ordinary clicks. The operation deadline still applies, and cancellation or timeout releases the held button. Keypad names such as KP_0 and KP_Enter retain their physical key identity. Window objects expose observation and pointer/AX actions; keyboard input remains app-scoped and follows current focus. Scroll deltas are integer lines from -10 to 10; positive values reveal content above or left. Paste delivers plain text or HTML through the application's enabled standard Paste menu command. Paste follows the target app's focused text input. Foreground requirements vary by application; for example, ChatGPT/Codex was observed to accept background Paste without reading clipboard data. PASTE_NOT_CONSUMED means the command was dispatched but no clipboard read was observed before the configured deadline. Inspect the target contents, explicitly activate the app and focus the intended input, then observe again before deciding whether to retry; a delayed paste may still run. Original clipboard formats are restored if the clipboard still belongs to this paste; a later copy is preserved. Unsupported Paste commands fail without typing fallback. CLIPBOARD_CHANGED means another writer intervened; observe before deciding on further input. Browser applications can be controlled with getApp like other native apps; browser tabs are unavailable. Installed-app search covers standard macOS application directories; use an absolute application path for another location. Launch does not restore a window or document, and cancellation may leave an application already started. The execution host must install the object API and own variable persistence; this plugin does not preload a cua global. In persistent PTC, use the host-provided initialization snippet below once. Its installation-owned file URL works independently of the project directory. Retain app objects between calls. Observation methods emit by default and also return their value. Use void await app.getAXState() to avoid a duplicate REPL completion, or pass emit:false and print the returned text once. End screenshot calls with void await app.getScreenshot() or void await app.getAXStateAndScreenshot() so image bytes are not serialized as the program completion value. ``` #### Token effect diff --git a/packages/experimental/cua-macos/README.zh.md b/packages/experimental/cua-macos/README.zh.md index ffbd21b79c..b01c0488d7 100644 --- a/packages/experimental/cua-macos/README.zh.md +++ b/packages/experimental/cua-macos/README.zh.md @@ -54,6 +54,7 @@ pnpm dsh headless --patch packages/experimental/cua-macos/examples/macos.cordis. | `pasteReadTimeoutMs` | `1000` | Paste 命令返回后等待剪贴板读取的上限;到期返回 `PASTE_NOT_CONSUMED`。 | | `maxAppMatches` | `20` | 已安装应用搜索的结果上限;范围过大的查询会被拒绝,需要缩小名称范围。 | | `maxElements` / `maxDepth` | `2000` / `25` | 辅助功能树遍历限制。 | +| `maxClickHoldMs` | `5000` | 显式点击按住时长的上限,单位为毫秒,接受非负整数。设为 0 时只允许立即松开。 | | `dragDurationMs` / `dragSteps` | `400` / `20` | 拖动时长和移动分段数。 | | `textEventIntervalMs` | `0.25` | 每个 Unicode 按下或抬起事件后的间隔,范围为 0 至 1000 毫秒。组合键的节奏独立。 | @@ -179,7 +180,7 @@ App and Window methods: - getScreenshot(options?: { emit?: boolean }) -> CuaScreenshot - getAXStateAndScreenshot(options?) -> { state: string, screenshot: CuaScreenshot } - hover(target: number | [x, y], options?: { relative?: boolean }) -> { dispatched, verified, hoverRoute, systemPointerMoved } -- click(target: number | [x, y], options?: { mouseButton?: "left" | "right" | "middle", clickCount?: 1 | 2, modifiers?: Array<"command" | "control" | "option" | "shift" | "fn">, relative?: boolean }) -> action result +- click(target: number | [x, y], options?: { mouseButton?: "left" | "right" | "middle", clickCount?: 1 | 2, holdMs?: number, modifiers?: Array<"command" | "control" | "option" | "shift" | "fn">, relative?: boolean }) -> action result - setValue(elementIndex: number, value: string) -> action result - selectText(elementIndex: number, text: string, options?: { prefix?: string, suffix?: string, selectionType?: "text" | "cursor_before" | "cursor_after" }) -> action result - performSecondaryAction(elementIndex: number, action: string) -> action result @@ -192,7 +193,7 @@ Await operations sequentially. After input, read fresh state to verify the effec Hover first uses advertised AX alternate UI without moving the system cursor, preserving revealed descendants until the pointer target leaves their region. When unavailable, it moves the visible system cursor without pressing a button; that route requires the target point to be unobscured in the foreground app. FOREGROUND_REQUIRED requires explicit activation or raising and a fresh observation before retrying. Returning, AX reads and screenshots do not restore the previous point. Wait for the application's hover delay, observe, then hover into the revealed controls before clicking. Use successive hover calls for intermediate points. The result reports hoverRoute and systemPointerMoved. Other input or application changes can interrupt the interaction; app objects do not reserve applications between calls. -Use selectText only on controls advertising selectionWritable. It selects a unique literal occurrence, or places the cursor before/after it; prefix and suffix must be immediately adjacent. AMBIGUOUS_TEXT requires more context; TEXT_NOT_FOUND or SELECTION_CHANGED requires a fresh observation before deciding on input. Left clicks support single/double clicks; right and middle clicks support single clicks. Modified clicks use pointer input. Keypad names such as KP_0 and KP_Enter retain their physical key identity. Window objects expose observation and pointer/AX actions; keyboard input remains app-scoped and follows current focus. Scroll deltas are integer lines from -10 to 10; positive values reveal content above or left. Paste delivers plain text or HTML through the application's enabled standard Paste menu command. Paste follows the target app's focused text input. Foreground requirements vary by application; for example, ChatGPT/Codex was observed to accept background Paste without reading clipboard data. PASTE_NOT_CONSUMED means the command was dispatched but no clipboard read was observed before the configured deadline. Inspect the target contents, explicitly activate the app and focus the intended input, then observe again before deciding whether to retry; a delayed paste may still run. Original clipboard formats are restored if the clipboard still belongs to this paste; a later copy is preserved. Unsupported Paste commands fail without typing fallback. CLIPBOARD_CHANGED means another writer intervened; observe before deciding on further input. Browser applications can be controlled with getApp like other native apps; browser tabs are unavailable. Installed-app search covers standard macOS application directories; use an absolute application path for another location. Launch does not restore a window or document, and cancellation may leave an application already started. The execution host must install the object API and own variable persistence; this plugin does not preload a cua global. In persistent PTC, use the host-provided initialization snippet below once. Its installation-owned file URL works independently of the project directory. Retain app objects between calls. Observation methods emit by default and also return their value. Use void await app.getAXState() to avoid a duplicate REPL completion, or pass emit:false and print the returned text once. End screenshot calls with void await app.getScreenshot() or void await app.getAXStateAndScreenshot() so image bytes are not serialized as the program completion value. +Use selectText only on controls advertising selectionWritable. It selects a unique literal occurrence, or places the cursor before/after it; prefix and suffix must be immediately adjacent. AMBIGUOUS_TEXT requires more context; TEXT_NOT_FOUND or SELECTION_CHANGED requires a fresh observation before deciding on input. Left clicks support single/double clicks; right and middle clicks support single clicks. Modified clicks use pointer input. Optional holdMs is a nonnegative integer in milliseconds for each press, including each press of a double-click. Zero releases without an additional hold. The configured maximum is stated below. It uses pointer input even for AX targets; omission preserves ordinary clicks. The operation deadline still applies, and cancellation or timeout releases the held button. Keypad names such as KP_0 and KP_Enter retain their physical key identity. Window objects expose observation and pointer/AX actions; keyboard input remains app-scoped and follows current focus. Scroll deltas are integer lines from -10 to 10; positive values reveal content above or left. Paste delivers plain text or HTML through the application's enabled standard Paste menu command. Paste follows the target app's focused text input. Foreground requirements vary by application; for example, ChatGPT/Codex was observed to accept background Paste without reading clipboard data. PASTE_NOT_CONSUMED means the command was dispatched but no clipboard read was observed before the configured deadline. Inspect the target contents, explicitly activate the app and focus the intended input, then observe again before deciding whether to retry; a delayed paste may still run. Original clipboard formats are restored if the clipboard still belongs to this paste; a later copy is preserved. Unsupported Paste commands fail without typing fallback. CLIPBOARD_CHANGED means another writer intervened; observe before deciding on further input. Browser applications can be controlled with getApp like other native apps; browser tabs are unavailable. Installed-app search covers standard macOS application directories; use an absolute application path for another location. Launch does not restore a window or document, and cancellation may leave an application already started. The execution host must install the object API and own variable persistence; this plugin does not preload a cua global. In persistent PTC, use the host-provided initialization snippet below once. Its installation-owned file URL works independently of the project directory. Retain app objects between calls. Observation methods emit by default and also return their value. Use void await app.getAXState() to avoid a duplicate REPL completion, or pass emit:false and print the returned text once. End screenshot calls with void await app.getScreenshot() or void await app.getAXStateAndScreenshot() so image bytes are not serialized as the program completion value. ``` #### Token 影响 diff --git a/packages/experimental/cua-macos/native/README.i18n.yaml b/packages/experimental/cua-macos/native/README.i18n.yaml index cdaaf8ba43..b25c409fb1 100644 --- a/packages/experimental/cua-macos/native/README.i18n.yaml +++ b/packages/experimental/cua-macos/native/README.i18n.yaml @@ -21,11 +21,11 @@ en: c8ad13843989b314 zh: 2a42acb2faee8803 /dsh-cua-native-helper/observation-and-input: - en: ce99636fae06e66c - zh: d3c983e8a47916b2 + en: c527eedc3a0ee968 + zh: ce8cc12d5a62e8ae /dsh-cua-native-helper/verification: - en: ba3277817f4da729 - zh: 4642abf830234a3b + en: a2ac6f2d581a1e8b + zh: f82c7d0c13539421 /dsh-cua-native-helper/further-exploration: en: 265e1927952ce6d7 zh: 7c35d9fd98b6e6c8 diff --git a/packages/experimental/cua-macos/native/README.md b/packages/experimental/cua-macos/native/README.md index 4299301dfc..b44d6a65f4 100644 --- a/packages/experimental/cua-macos/native/README.md +++ b/packages/experimental/cua-macos/native/README.md @@ -69,7 +69,9 @@ Protocol declarations are generated from [the shared protocol](../../cua/src/pro `getState` optionally scopes AX traversal to an owned window or nested sheet `windowId`. A new or failed refresh invalidates the previous snapshot for that app/window scope, while other window scopes remain usable. When an owned CG window has no matching AX surface, getState returns a new snapshot with empty tree/elements and no truncation; its coordinates and screenshots remain available. It rechecks process lifetime, window ownership, the unlocked session and Accessibility permission after traversal. Window-discovery APIDisabled and communication errors remain failures; unsupported or absent optional AX attributes can produce an empty observation. Missing or foreign-owned windows never produce an empty success. An AX element invalidated during traversal rejects the whole read with `STALE_SNAPSHOT`; no partial snapshot is published. Callers may read again after the application changes, without repeating input. Element actions require the exact snapshot ID. Native references never cross JSON. An optional snapshot ID on coordinate actions must belong to that window or an app-wide observation. Window-list refresh releases snapshots for closed windows. Native process identity, snapshot generation, and window ownership checks do not lock the desktop against simultaneous user or application changes. -Element clicks prefer advertised AX actions and otherwise use pointer delivery. Coordinate click, scroll and drag validate window ownership and bounds, then use background PID/window-directed primitives. Each event uses SkyLight when available, otherwise the public PID-targeted API. A posted event is never repeated through the other API. Coordinates are macOS points from the top-left of the entire window, including its title bar. Positive `deltaLinesY` reveals content above; negative reveals below. Positive `deltaLinesX` reveals content left; negative reveals right. Each axis accepts an integer from -10 to 10, and at least one must be nonzero. The helper sends line-unit scroll events directly. Click and drag accept left/right/middle buttons and command/control/option/shift/fn modifiers. Only left clicks support double-click. Modified element clicks use pointer delivery. Drag uses one button press/move/release in one window; callers explicitly supply durationMs (1..5000) and steps (1..240). The movement delay budget is divided with fractional precision across the segments; fixed press/release settling and scheduler latency are additional. No implicit fallback sends global desktop input. +Element clicks without holdMs prefer advertised AX actions and otherwise use pointer delivery. Coordinate click, scroll and drag validate window ownership and bounds, then use background PID/window-directed primitives. Each event uses SkyLight when available, otherwise the public PID-targeted API. A posted event is never repeated through the other API. Coordinates are macOS points from the top-left of the entire window, including its title bar. Positive `deltaLinesY` reveals content above; negative reveals below. Positive `deltaLinesX` reveals content left; negative reveals right. Each axis accepts an integer from -10 to 10, and at least one must be nonzero. The helper sends line-unit scroll events directly. Click and drag accept left/right/middle buttons and command/control/option/shift/fn modifiers. Only left clicks support double-click. Modified element clicks use pointer delivery. Drag uses one button press/move/release in one window; callers explicitly supply durationMs (1..5000) and steps (1..240). The movement delay budget is divided with fractional precision across the segments; fixed press/release settling and scheduler latency are additional. No implicit fallback sends global desktop input. + +`click.holdMs` is an optional nonnegative integer in milliseconds per press; zero adds no hold delay. Each connection supplies `maxClickHoldMs` to bound explicit holds. The diagnostic helper option `--max-click-hold-ms` defaults to 5000. It bypasses AX actions and uses the element center or supplied coordinates. Omission retains ordinary click timing. The complete gesture holds the request scheduler slot; cancellation, timeout and disconnect interrupt the hold and post the pending mouse-up before releasing that slot. Received events remain effective, and forced process termination cannot guarantee cleanup. Capture and pointer delivery read CG bounds before and after an exact-window AX frame query. Movement during the query returns WINDOW_CHANGED. Valid AX/CG frames must have equal outward-rounded integral point rectangles; a known mismatch returns WINDOW_CHANGED before mouse movement, priming or button delivery. The helper preserves both coordinate sources rather than substituting AX geometry for CG geometry. Missing or unusable AX geometry remains unknown, while AX communication, permission and invalid-element errors propagate. Capture without Accessibility permission retains its CG/SCK checks. Advertised AX actions bypass this pointer preflight; an AX action error does not trigger pointer input. These reads do not lock the window against later movement, and no geometry check activates or raises it. @@ -81,7 +83,7 @@ Host-only permission controls are `permissionStatus {}`, `requestPermission {per Observation reads each non-layout node’s descriptive properties and children through `AXUIElementCopyMultipleAttributeValues`. Per-attribute errors retain the individual-read rules; unsupported batch APIs use individual reads. Values are retained only during that observation, and input reads live attributes. Application state can change between nodes; a batch does not make the whole tree atomic. -System file-panel keyboard routing resolves the remote recipient through the selected app’s focused AX sheet. Only Apple’s Open and Save Panel Service is accepted, and its focused ancestry must still contain the identified remote window. Element pointer fallbacks resolve an app-owned window or sheet through AX parents when AXWindow is unavailable. AX actions retain priority over pointer input; visual click markers need no system-cursor movement. +System file-panel keyboard routing resolves the remote recipient through the selected app’s focused AX sheet. Only Apple’s Open and Save Panel Service is accepted, and its focused ancestry must still contain the identified remote window. Element pointer fallbacks resolve an app-owned window or sheet through AX parents when AXWindow is unavailable. Ordinary clicks retain AX action priority over pointer input; visual click markers need no system-cursor movement. Request modifier arrays accept command/control/option/shift/fn. The object API normalizes key-chord aliases before dispatch. @@ -119,7 +121,7 @@ For text pacing comparisons, use `native/tests/text_input_benchmark.py` with `-- For an independently isolated Electron/Chromium check, run `python3 packages/experimental/cua-macos/native/tests/electron_e2e.py --electron /absolute/path/to/Electron.app/Contents/MacOS/Electron`. Omitting `--electron` uses an already installed desktop workspace runtime. The check never downloads Electron. Its renderer runs with Node integration disabled, context isolation and Chromium sandboxing enabled, private userData/sessionData/log directories, and a local fixture page. Renderer click/input events write independent observations through the main process; all test actions still use the native helper. AX polling waits for actual controls after Chromium accessibility activation. Native and Electron subprocess groups are owned and stopped before temporary files are removed. Optional artifacts include a fixture-only screenshot and private request transcript. -Run `python3 packages/experimental/cua-macos/native/tests/capabilities_e2e.py --helper /absolute/helper --artifacts /private/evidence` to verify a stopped application's discovery and launch, Unicode selection, multiline plain/HTML paste, middle/modifier gestures and keypad identities through an isolated AppKit receiver. It requires existing Accessibility/Screen Recording grants and records the receiver's actual state separately from dispatch results. +Run `python3 packages/experimental/cua-macos/native/tests/capabilities_e2e.py --helper /absolute/helper --artifacts /private/evidence` to verify a stopped application's discovery and launch, Unicode selection, multiline plain/HTML paste, middle/modifier gestures and keypad identities through an isolated AppKit receiver. Add `--click-only` to check click holds, AX routing and cancellation/timeout release in a background fixture without keyboard or clipboard input. It requires existing Accessibility/Screen Recording grants and records the receiver's actual state separately from dispatch results. Run `python3 packages/experimental/cua-macos/native/tests/paste_e2e.py --artifacts /private/evidence` to verify that an owned AppKit app accepts a background Paste action without reading its data, reports `PASTE_NOT_CONSUMED`, then consumes the same paste in the foreground. The test requires an unlocked Mac and Accessibility permission, activates only its own two applications, and records received actions and text independently of helper replies. diff --git a/packages/experimental/cua-macos/native/README.zh.md b/packages/experimental/cua-macos/native/README.zh.md index 6c5e4b5f8a..ecefb9c879 100644 --- a/packages/experimental/cua-macos/native/README.zh.md +++ b/packages/experimental/cua-macos/native/README.zh.md @@ -69,7 +69,9 @@ AppKit 在主事件循环上运行。阻塞式 socket 接收与握手使用后 `getState` 可将 AX 遍历限定到应用所属窗口或嵌套 sheet 的 `windowId`。新的刷新或失败的刷新使同一应用/窗口范围的旧快照失效,其他窗口范围仍可使用。应用所属 CG 窗口没有匹配 AX 界面时,getState 返回空 tree/elements、truncated 为 false 的新快照,仍可截图和坐标输入。遍历后重新检查进程生命期、窗口归属、会话未锁定及辅助功能权限。窗口发现中的 APIDisabled 和通信错误仍会失败;不支持或缺失的可选 AX 属性可以产生空观察。不存在或属于其他应用的窗口不会返回空成功结果。遍历中 AX 元素失效会以 `STALE_SNAPSHOT` 拒绝整次读取,不发布部分树。应用变化后可以重新观察,但不重复输入。元素动作要求精确的快照 ID,原生引用不跨 JSON 传递。坐标动作的可选快照 ID 必须属于该窗口或应用级观察。刷新窗口列表会释放已关闭窗口的快照。进程身份、快照代次和窗口归属检查不会锁住桌面,用户或应用仍可同时改变状态。 -元素点击优先使用声明的 AX 动作,否则通过鼠标事件发送。坐标点击、滚动和拖动核实窗口归属和边界后,使用后台 PID/窗口定向输入。事件优先使用 SkyLight,不可用时使用公开的 PID 定向 API;已发送的事件不通过另一 API 重发。坐标是整个窗口左上角起算的 macOS point,包含标题栏。`deltaLinesY` 为正时显示上方内容,为负时显示下方内容;`deltaLinesX` 为正时显示左侧内容,为负时显示右侧内容。每个轴接受 -10 到 10 的整数,至少一个轴非零。helper 直接发送按行计量的滚轮事件。点击和拖动支持左、右、中键及 command/control/option/shift/fn 修饰键,只有左键支持双击。带修饰键的元素点击使用鼠标输入。拖动在一个窗口中完成一次按下、移动、抬起,调用方显式提供 durationMs(1..5000)和 steps(1..240)。移动延迟按小数精度分配到各段,按下和抬起的固定等待及调度延迟另计。不存在隐式改用全局桌面输入的后备路径。 +未指定 holdMs 的元素点击优先使用声明的 AX 动作,否则通过鼠标事件发送。坐标点击、滚动和拖动核实窗口归属和边界后,使用后台 PID/窗口定向输入。事件优先使用 SkyLight,不可用时使用公开的 PID 定向 API;已发送的事件不通过另一 API 重发。坐标是整个窗口左上角起算的 macOS point,包含标题栏。`deltaLinesY` 为正时显示上方内容,为负时显示下方内容;`deltaLinesX` 为正时显示左侧内容,为负时显示右侧内容。每个轴接受 -10 到 10 的整数,至少一个轴非零。helper 直接发送按行计量的滚轮事件。点击和拖动支持左、右、中键及 command/control/option/shift/fn 修饰键,只有左键支持双击。带修饰键的元素点击使用鼠标输入。拖动在一个窗口中完成一次按下、移动、抬起,调用方显式提供 durationMs(1..5000)和 steps(1..240)。移动延迟按小数精度分配到各段,按下和抬起的固定等待及调度延迟另计。不存在隐式改用全局桌面输入的后备路径。 + +`click.holdMs` 是可选的每次按住时长,单位为毫秒,接受非负整数;0 表示不额外等待。每个连接通过 `maxClickHoldMs` 限制显式按住时长。诊断 helper 的 `--max-click-hold-ms` 选项默认 5000。指定后使用元素中心或给定坐标发送鼠标事件,跳过 AX 动作。省略时保留普通点击时长。整个手势占用请求调度槽位;取消、超时和断开连接会中断等待,在释放槽位前发送对应的松开事件。已接收的事件仍然生效,进程被强制终止时无法保证清理。 截图和鼠标输入在精确窗口的 AX 边界查询前后读取 CG 边界,查询期间移动会返回 WINDOW_CHANGED。有效的 AX/CG 边界向外取整后必须一致;已知不一致会在移动、预备事件或按键发送前返回 WINDOW_CHANGED。helper 保留两个坐标来源,不用 AX 几何替换 CG 几何。缺失或无效的 AX 几何仍表示未知,AX 通信、权限和无效元素错误继续传递。无辅助功能权限的截图保留 CG/SCK 检查。声明的 AX 动作不经过鼠标预检,AX 动作失败也不改用鼠标输入。这些读取不会锁住窗口以阻止后续移动,几何检查不激活或抬起窗口。 @@ -81,7 +83,7 @@ AppKit 在主事件循环上运行。阻塞式 socket 接收与握手使用后 观察通过 `AXUIElementCopyMultipleAttributeValues` 读取每个非布局节点的描述属性和子节点。逐属性错误保留单独读取的规则;不支持批量 API 时使用单独读取。值只在本次观察期间保留,输入读取即时属性。应用状态可能在节点之间变化,批量读取不使整棵树具备原子性。 -系统文件面板的键盘路由通过所选应用的 AX 焦点 sheet 解析远端接收方。只接受 Apple 的 Open and Save Panel Service,且其焦点祖先链仍须包含已识别的远端窗口。AXWindow 不可用时,元素鼠标后备路径沿 AX 父节点解析应用所属窗口或 sheet。AX 动作保持优先,可视点击标记不需要系统鼠标移动。 +系统文件面板的键盘路由通过所选应用的 AX 焦点 sheet 解析远端接收方。只接受 Apple 的 Open and Save Panel Service,且其焦点祖先链仍须包含已识别的远端窗口。AXWindow 不可用时,元素鼠标后备路径沿 AX 父节点解析应用所属窗口或 sheet。普通点击保持 AX 动作优先,可视点击标记不需要系统鼠标移动。 请求中的修饰键数组接受 command/control/option/shift/fn。对象 API 在分派前统一组合键别名。 @@ -119,7 +121,7 @@ Blender 键盘检查使用 `python3 packages/experimental/cua-macos/native/tests 隔离的 Electron/Chromium 检查使用 `python3 packages/experimental/cua-macos/native/tests/electron_e2e.py --electron /absolute/path/to/Electron.app/Contents/MacOS/Electron`。省略 `--electron` 时使用桌面工作区已安装的运行时,检查不下载 Electron。渲染器禁用 Node 集成,启用上下文隔离和 Chromium 沙箱,使用独立 userData/sessionData/log 目录及本地 fixture 页面。渲染器的点击/输入事件通过主进程写出独立观察,测试动作仍通过原生 helper 执行。AX 轮询等待 Chromium 辅助功能启用后的真实控件。原生和 Electron 子进程组由测试持有,在删除临时文件前停止。可选产物包含仅有 fixture 的截图和私有请求记录。 -运行 `python3 packages/experimental/cua-macos/native/tests/capabilities_e2e.py --helper /absolute/helper --artifacts /private/evidence`,通过隔离的 AppKit 接收方验证未运行应用的发现和启动、Unicode 选区、多行纯文本/HTML 粘贴、中键/修饰键手势及数字键盘身份。需要已有辅助功能/屏幕录制权限,接收方实际状态与分派结果分开记录。 +运行 `python3 packages/experimental/cua-macos/native/tests/capabilities_e2e.py --helper /absolute/helper --artifacts /private/evidence`,通过隔离的 AppKit 接收方验证未运行应用的发现和启动、Unicode 选区、多行纯文本/HTML 粘贴、中键/修饰键手势及数字键盘身份。需要已有辅助功能/屏幕录制权限,接收方实际状态与分派结果分开记录。 添加 `--click-only` 可在后台测试窗口中验证按住时长、AX 路由和取消、超时后的松开事件,不发送键盘或剪贴板输入。 运行 `python3 packages/experimental/cua-macos/native/tests/paste_e2e.py --artifacts /private/evidence`,验证自有 AppKit 应用在后台接收 Paste 动作但不读取数据时返回 `PASTE_NOT_CONSUMED`,以及前台读取同一次粘贴后成功。测试要求 Mac 已解锁并已授予辅助功能权限,只激活自有的两个应用,并独立记录收到的动作和文本以核对 helper 回复。 diff --git a/packages/experimental/cua-macos/native/Sources/CuaNative/AXWorker.swift b/packages/experimental/cua-macos/native/Sources/CuaNative/AXWorker.swift index 80c8df17af..c11c78dc2e 100644 --- a/packages/experimental/cua-macos/native/Sources/CuaNative/AXWorker.swift +++ b/packages/experimental/cua-macos/native/Sources/CuaNative/AXWorker.swift @@ -1,4 +1,4 @@ -// Actor-owned native identities, scoped AX snapshots and synchronous desktop operations. +// Actor-owned native identities, scoped AX snapshots and desktop operations. import Foundation import Dispatch import ApplicationServices @@ -78,16 +78,18 @@ actor AXWorker { private let cancellation: CancellationLatch private let axTimeout: Float private let textEventInterval: Double + private let maxClickHoldMs: Int private var access: AXAccess? private var keyboard: KeyboardInput? private var pointer: WindowPointerInput? private var hovers: [String: AXHover] = [:] private var closed = false - init(cancellation: CancellationLatch, axTimeout: Float, textEventInterval: Double) { + init(cancellation: CancellationLatch, axTimeout: Float, textEventInterval: Double, maxClickHoldMs: Int) { self.cancellation = cancellation self.axTimeout = axTimeout self.textEventInterval = textEventInterval + self.maxClickHoldMs = maxClickHoldMs } private func identifier(_ kind: String) throws -> String { guard generation < UInt64.max else { throw NativeFailure(code: "NATIVE_ERROR", message: "Native identity space exhausted") } @@ -199,7 +201,7 @@ actor AXWorker { } func execute(_ method: String, params: RequestParameters, catalog: ApplicationCatalogSnapshot, installed: InstalledApplication? = nil, - trace: ScreenshotTrace? = nil) throws -> DriverOutcome { + trace: ScreenshotTrace? = nil) async throws -> DriverOutcome { try cancellation.check() guard !closed else { throw NativeFailure(code: "NATIVE_CLOSED", message: "Helper is closing") } let locked = observeSession() @@ -343,6 +345,7 @@ actor AXWorker { } try ax().perform(selected, action) case "click", "hover": + let holdMs = params.contains("holdMs") ? try params.integer("holdMs", in: 0...maxClickHoldMs) : nil let button = try params.optionalString("mouseButton") ?? "left" let count = params.contains("clickCount") ? try params.integer("clickCount", in: 1...2) : 1 guard button == "left" || (["right", "middle"].contains(button) && count == 1) else { throw RequestParameters.invalid("Unsupported mouse button or click count") } @@ -356,7 +359,7 @@ actor AXWorker { hoverElement = selected if method == "click" { let names = try ax().actions(selected) - let action: String? = !modifiers.isEmpty ? nil : button == "right" && names.contains("AXShowMenu") ? "AXShowMenu" + let action: String? = holdMs != nil || !modifiers.isEmpty ? nil : button == "right" && names.contains("AXShowMenu") ? "AXShowMenu" : (button == "left" && count == 1 ? (names.contains("AXPress") ? "AXPress" : names.contains("AXPick") ? "AXPick" : nil) : nil) if let action { try ax().perform(selected, action) @@ -387,7 +390,7 @@ actor AXWorker { } return .hover(route, point) } - try inputs().1.click(in: route, at: point, button: button, count: count, modifiers: modifiers) + try await inputs().1.click(in: route, at: point, button: button, count: count, modifiers: modifiers, holdMs: holdMs) case "scroll", "drag": let id = try params.windowID() try coordinateSnapshot(handle, params, id) diff --git a/packages/experimental/cua-macos/native/Sources/CuaNative/NativeProtocol.generated.swift b/packages/experimental/cua-macos/native/Sources/CuaNative/NativeProtocol.generated.swift index 62d52a787e..fd8ce9cf0b 100644 --- a/packages/experimental/cua-macos/native/Sources/CuaNative/NativeProtocol.generated.swift +++ b/packages/experimental/cua-macos/native/Sources/CuaNative/NativeProtocol.generated.swift @@ -4,7 +4,7 @@ enum NativeProtocol { static let fields: [String: Set] = [ "activate": ["appHandle"], "applicationIcons": ["paths"], - "click": ["appHandle", "clickCount", "elementIndex", "modifiers", "mouseButton", "snapshotId", "windowId", "x", "y"], + "click": ["appHandle", "clickCount", "elementIndex", "holdMs", "modifiers", "mouseButton", "snapshotId", "windowId", "x", "y"], "drag": ["appHandle", "durationMs", "fromX", "fromY", "modifiers", "mouseButton", "snapshotId", "steps", "toX", "toY", "windowId"], "findApps": ["maxAppMatches", "query"], "getApp": ["selector"], diff --git a/packages/experimental/cua-macos/native/Sources/CuaNative/RequestCoordinator.swift b/packages/experimental/cua-macos/native/Sources/CuaNative/RequestCoordinator.swift index 8931d3bfff..280cd358ad 100644 --- a/packages/experimental/cua-macos/native/Sources/CuaNative/RequestCoordinator.swift +++ b/packages/experimental/cua-macos/native/Sources/CuaNative/RequestCoordinator.swift @@ -40,7 +40,7 @@ import Darwin self.onClose = onClose self.onFatal = onFatal worker = AXWorker(cancellation: cancellation, axTimeout: configuration.axTimeoutSeconds, - textEventInterval: configuration.textEventIntervalSeconds) + textEventInterval: configuration.textEventIntervalSeconds, maxClickHoldMs: configuration.maxClickHoldMs) input = JSONLInput(limit: configuration.maxRequestBytes, cancellation: cancellation, channel: channel) self.writer = writer } diff --git a/packages/experimental/cua-macos/native/Sources/CuaNative/Runtime.swift b/packages/experimental/cua-macos/native/Sources/CuaNative/Runtime.swift index e38ece600d..d61f0803ab 100644 --- a/packages/experimental/cua-macos/native/Sources/CuaNative/Runtime.swift +++ b/packages/experimental/cua-macos/native/Sources/CuaNative/Runtime.swift @@ -4,7 +4,7 @@ import Dispatch import Darwin final class CancellationLatch: @unchecked Sendable { - // The synchronous AX/input loops cannot await a cancellation message on their own actor. + // Blocking AX calls and synchronous input loops also observe connection cancellation. private let lock = NSLock() private var cancelled = false func cancel() { lock.withLock { cancelled = true } } @@ -20,6 +20,7 @@ struct HelperConfiguration: Sendable { var axTimeoutSeconds: Float = 30 var shutdownGraceSeconds: Double = 3 var textEventIntervalSeconds: Double = 0.00025 + var maxClickHoldMs = 5000 var timeoutSeconds: Double = 30 var screenshotTimeoutSeconds: Double = 5 init(arguments: [String]) throws { @@ -29,6 +30,11 @@ struct HelperConfiguration: Sendable { let key = arguments[index] let text = arguments[index + 1] switch key { + case "--max-click-hold-ms": + guard let value = Int(text), (0...2147483647).contains(value) else { + throw RequestParameters.invalid("Maximum click hold must be a nonnegative integer within 2147483647 milliseconds") + } + maxClickHoldMs = value case "--max-request-bytes", "--max-response-bytes": guard let value = Int(text), value >= 1024, value < Int.max else { throw RequestParameters.invalid("\(key) must be a positive byte limit") } if key == "--max-request-bytes" { maxRequestBytes = value } else { maxResponseBytes = value } @@ -58,12 +64,12 @@ struct HelperConfiguration: Sendable { guard let fields = decoded.object, Set(fields.keys) == ["type", "protocol", "runtimeKey", "config"], fields["type"] == .string("hello"), fields["protocol"] == .number(1), fields["runtimeKey"] == .string(runtimeKey), let config = fields["config"]?.object, - Set(config.keys) == ["maxRequestBytes", "maxResponseBytes", "axTimeoutMs", "textEventIntervalMs", "screenshotTimeoutMs", "timeoutMs", "graceMs"] else { + Set(config.keys) == ["maxRequestBytes", "maxResponseBytes", "axTimeoutMs", "textEventIntervalMs", "maxClickHoldMs", "screenshotTimeoutMs", "timeoutMs", "graceMs"] else { throw NativeFailure(code: "NATIVE_PROTOCOL", message: "Expected shared helper protocol 1 hello for this runtime") } func number(_ name: String) throws -> String { guard let number = config[name]?.number, number.isFinite else { throw RequestParameters.invalid("\(name) must be finite") } - if ["maxRequestBytes", "maxResponseBytes"].contains(name) { + if ["maxRequestBytes", "maxResponseBytes", "maxClickHoldMs"].contains(name) { guard let integer = Int(exactly: number) else { throw RequestParameters.invalid("\(name) must be an integer") } return String(integer) } @@ -72,6 +78,7 @@ struct HelperConfiguration: Sendable { try self.init(arguments: [ "--max-request-bytes", number("maxRequestBytes"), "--max-response-bytes", number("maxResponseBytes"), "--ax-timeout-ms", number("axTimeoutMs"), "--text-event-interval-ms", number("textEventIntervalMs"), + "--max-click-hold-ms", number("maxClickHoldMs"), "--screenshot-timeout-ms", number("screenshotTimeoutMs"), "--timeout-ms", number("timeoutMs"), "--shutdown-grace-ms", number("graceMs"), ]) diff --git a/packages/experimental/cua-macos/native/Sources/CuaNative/SharedHelperProxy.swift b/packages/experimental/cua-macos/native/Sources/CuaNative/SharedHelperProxy.swift index cffb83da0e..071c5eecca 100644 --- a/packages/experimental/cua-macos/native/Sources/CuaNative/SharedHelperProxy.swift +++ b/packages/experimental/cua-macos/native/Sources/CuaNative/SharedHelperProxy.swift @@ -15,6 +15,7 @@ enum SharedHelperProxy { let hello = JSONValue.object(["type": .string("hello"), "protocol": .number(1), "runtimeKey": .string(digest), "config": .object([ "maxRequestBytes": .number(Double(configuration.maxRequestBytes)), "maxResponseBytes": .number(Double(configuration.maxResponseBytes)), "axTimeoutMs": .number(Double(configuration.axTimeoutSeconds) * 1000), "textEventIntervalMs": .number(configuration.textEventIntervalSeconds * 1000), + "maxClickHoldMs": .number(Double(configuration.maxClickHoldMs)), "screenshotTimeoutMs": .number(configuration.screenshotTimeoutSeconds * 1000), "timeoutMs": .number(configuration.timeoutSeconds * 1000), "graceMs": .number(configuration.shutdownGraceSeconds * 1000), ])]) diff --git a/packages/experimental/cua-macos/native/Sources/CuaNative/WindowPointerInput.swift b/packages/experimental/cua-macos/native/Sources/CuaNative/WindowPointerInput.swift index 2dc34a1b27..ab91413090 100644 --- a/packages/experimental/cua-macos/native/Sources/CuaNative/WindowPointerInput.swift +++ b/packages/experimental/cua-macos/native/Sources/CuaNative/WindowPointerInput.swift @@ -57,8 +57,9 @@ final class WindowPointerInput { } } - /// Post one or two left clicks, or one right/middle click, with a shared window-routing group. - func click(in route: WindowRoute, at point: WindowPoint, button: String, count: Int, modifiers: [String] = []) throws { + /// Post balanced mouse presses; holdMs replaces each press-to-release interval and remains cancellable. + func click(in route: WindowRoute, at point: WindowPoint, button: String, count: Int, modifiers: [String] = [], + holdMs: Int? = nil, isolation: isolated (any Actor)? = #isolation) async throws { try checkCancellation() try Self.validate(point, in: route) guard (button == "left" && (count == 1 || count == 2)) || (["right", "middle"].contains(button) && count == 1) else { @@ -84,10 +85,11 @@ final class WindowPointerInput { } try checkCancellation() poster.post(move, to: route.owner.pid) - Thread.sleep(forTimeInterval: 0.015) + try await Task.sleep(for: .milliseconds(15)) for (index, pair) in pairs.enumerated() { - try postPair(pair, to: route.owner.pid, downInterval: 0.001) - if index + 1 < pairs.count { Thread.sleep(forTimeInterval: 0.080) } + try await Self.postPair(downInterval: .milliseconds(holdMs ?? 1), checkCancellation: checkCancellation, + down: { poster.post(pair.down, to: route.owner.pid) }, up: { poster.post(pair.up, to: route.owner.pid) }) + if index + 1 < pairs.count { try await Task.sleep(for: .milliseconds(80)) } } } else { let pair = try mousePair( @@ -96,8 +98,9 @@ final class WindowPointerInput { ) try checkCancellation() poster.post(move, to: route.owner.pid) - Thread.sleep(forTimeInterval: 0.012) - try postPair(pair, to: route.owner.pid, downInterval: 0.028) + try await Task.sleep(for: .milliseconds(12)) + try await Self.postPair(downInterval: .milliseconds(holdMs ?? 28), checkCancellation: checkCancellation, + down: { poster.post(pair.down, to: route.owner.pid) }, up: { poster.post(pair.up, to: route.owner.pid) }) } try checkCancellation() } @@ -206,18 +209,14 @@ final class WindowPointerInput { try checkCancellation() } - private func postPair(_ pair: MousePair, to pid: Int32, downInterval: TimeInterval) throws { - var held = false - defer { - if held { poster.post(pair.up, to: pid) } - } + /// Every posted down is paired with exactly one up, including cancellation during the wait. + static func postPair(downInterval: Duration, checkCancellation: () throws -> Void, + down: () -> Void, up: () -> Void, isolation: isolated (any Actor)? = #isolation) async throws { try checkCancellation() - poster.post(pair.down, to: pid) - held = true - Thread.sleep(forTimeInterval: downInterval) + down() + defer { up() } + try await Task.sleep(for: downInterval) try checkCancellation() - poster.post(pair.up, to: pid) - held = false } private func mousePair( diff --git a/packages/experimental/cua-macos/native/protocol.schema.json b/packages/experimental/cua-macos/native/protocol.schema.json index e66efb5989..d1ec46702e 100644 --- a/packages/experimental/cua-macos/native/protocol.schema.json +++ b/packages/experimental/cua-macos/native/protocol.schema.json @@ -412,6 +412,12 @@ "const": 2 } ] + }, + "holdMs": { + "description": "Milliseconds between each mouse press and release; omitted for an ordinary click.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 } }, "required": [ @@ -477,6 +483,12 @@ "const": 2 } ] + }, + "holdMs": { + "description": "Milliseconds between each mouse press and release; omitted for an ordinary click.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 } }, "required": [ diff --git a/packages/experimental/cua-macos/native/tests/CapabilitiesFixture.swift b/packages/experimental/cua-macos/native/tests/CapabilitiesFixture.swift index babf40415f..c10d14fa31 100644 --- a/packages/experimental/cua-macos/native/tests/CapabilitiesFixture.swift +++ b/packages/experimental/cua-macos/native/tests/CapabilitiesFixture.swift @@ -3,6 +3,10 @@ import AppKit final class CapabilityPad: NSView { var received: ((NSEvent) -> Void)? + var pressed: (() -> Void)? + override func isAccessibilityElement() -> Bool { true } + override func accessibilityRole() -> NSAccessibility.Role? { .button } + override func accessibilityPerformPress() -> Bool { pressed?(); return true } override var isFlipped: Bool { true } override var acceptsFirstResponder: Bool { true } override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } @@ -41,6 +45,7 @@ final class CapabilitiesFixture: NSObject, NSApplicationDelegate, NSWindowDelega var pasteAttempts: [Bool] = [] var pad: CapabilityPad! var events: [[String: Any]] = [] + var axPresses = 0 init(statePath: String) { self.statePath = statePath } func applicationDidFinishLaunching(_ notification: Notification) { let bar = NSMenu() @@ -70,10 +75,11 @@ final class CapabilitiesFixture: NSObject, NSApplicationDelegate, NSWindowDelega text.attemptedPaste = { [weak self] owned in self?.pasteAttempts.append(owned); self?.persist() } window.contentView!.addSubview(text) pad = CapabilityPad(frame: NSRect(x: 20, y: 25, width: 600, height: 170)) - pad.setAccessibilityLabel("Input receiver") + pad.setAccessibilityTitle("Input receiver") + pad.pressed = { [weak self] in self?.axPresses += 1; self?.persist() } pad.received = { [weak self] event in guard let self else { return } - var value: [String: Any] = ["type": event.type.rawValue, "flags": event.modifierFlags.rawValue, + var value: [String: Any] = ["type": event.type.rawValue, "timestamp": event.timestamp, "flags": event.modifierFlags.rawValue, "shift": event.modifierFlags.contains(.shift), "command": event.modifierFlags.contains(.command)] if [.keyDown, .keyUp].contains(event.type) { value["keyCode"] = event.keyCode; value["characters"] = event.characters ?? "" } else { value["button"] = event.buttonNumber } @@ -81,9 +87,13 @@ final class CapabilitiesFixture: NSObject, NSApplicationDelegate, NSWindowDelega self.persist() } window.contentView!.addSubview(pad) - window.makeKeyAndOrderFront(nil) - window.makeFirstResponder(text) - NSApplication.shared.activate(ignoringOtherApps: true) + if Bundle.main.object(forInfoDictionaryKey: "DSHFixtureBackground") as? Bool == true { + window.orderFront(nil) + } else { + window.makeKeyAndOrderFront(nil) + window.makeFirstResponder(text) + NSApplication.shared.activate(ignoringOtherApps: true) + } persist() } func applicationDidBecomeActive(_ notification: Notification) { persist() } @@ -109,7 +119,7 @@ final class CapabilitiesFixture: NSObject, NSApplicationDelegate, NSWindowDelega let result: [String: Any] = ["pid": ProcessInfo.processInfo.processIdentifier, "windowId": window.windowNumber, "active": NSApplication.shared.isActive, "keyWindow": window.isKeyWindow, "textFocused": window.firstResponder === text, "pasteAttempts": pasteAttempts, - "text": text.string, "selection": [selection.location, selection.length], "runs": runs, "events": events, + "text": text.string, "selection": [selection.location, selection.length], "runs": runs, "events": events, "axPresses": axPresses, "pad": [frame.minX, window.frame.height - frame.maxY, frame.width, frame.height]] try! JSONSerialization.data(withJSONObject: result).write(to: URL(fileURLWithPath: statePath), options: .atomic) } diff --git a/packages/experimental/cua-macos/native/tests/capabilities_e2e.py b/packages/experimental/cua-macos/native/tests/capabilities_e2e.py index 6050745c5d..13bd21923f 100644 --- a/packages/experimental/cua-macos/native/tests/capabilities_e2e.py +++ b/packages/experimental/cua-macos/native/tests/capabilities_e2e.py @@ -2,6 +2,7 @@ import argparse import base64 import json +import hashlib import os from pathlib import Path import plistlib @@ -12,6 +13,7 @@ import time import uuid from native_test_support import ROOT, NativeHelper, run_cleanup +from shared_helper_e2e import Connection def wait_for_state(path, predicate): @@ -44,10 +46,83 @@ def stop_application(pid): raise AssertionError("fixture did not exit after SIGTERM") +def check_click_holds(helper, owner, window, point, state_file): + observed = helper.call("getState", {**owner, "maxElements": 2000, "maxDepth": 25}) + pad = next(item for item in observed["elements"] if item["role"] == "AXButton" and item["title"] == "Input receiver") + target = {**owner, "snapshotId": observed["snapshotId"], "elementIndex": pad["index"]} + state = json.loads(state_file.read_text()) + presses = state["axPresses"] + helper.call("click", target) + state = wait_for_state(state_file, lambda value: value["axPresses"] == presses + 1) + count = len(state["events"]) + for invalid in [-1, 1.5, 5001, "800", None]: + helper.call("click", {**target, "holdMs": invalid}, "INVALID_ARGUMENT") + assert json.loads(state_file.read_text())["axPresses"] == presses + 1 + for params, types in [({**target, "holdMs": 0}, [1, 2]), + ({**target, "holdMs": 150}, [1, 2]), + ({**window, **point, "holdMs": 150, "mouseButton": "right"}, [3, 4]), + ({**window, **point, "holdMs": 150, "mouseButton": "middle"}, [25, 26]), + ({**window, **point, "holdMs": 150, "clickCount": 2}, [1, 2, 1, 2])]: + helper.call("click", params) + state = wait_for_state(state_file, lambda value: len(value["events"]) >= count + len(types)) + events = state["events"][count:] + assert [event["type"] for event in events] == types, events + for index in range(0, len(events), 2): + assert events[index + 1]["timestamp"] - events[index]["timestamp"] >= params["holdMs"] / 1000, events + assert state["axPresses"] == presses + 1, state + count += len(types) + # Production cancellation travels directly over the socket; the diagnostic stdio proxy exchanges whole requests. + key = hashlib.sha256(Path(helper.process.args[0]).read_bytes()).hexdigest() + socket_path = Path(f"/tmp/dsh-cua-{os.getuid()}/{key}.sock") + for ending in ["cancel", "timeout", "disconnect"]: + connection = Connection(socket_path, key, maxClickHoldMs=7000, timeoutMs=2000 if ending == "timeout" else 30000) + try: + app = connection.call("getApp", {"selector": str(observed["pid"])}) + connection.call("getState", {"appHandle": app["appHandle"], "windowId": window["windowId"], + "maxElements": 2000, "maxDepth": 25}) + params = {**window, **point, "appHandle": app["appHandle"], "holdMs": 6000} + count = len(json.loads(state_file.read_text())["events"]) + connection.call("click", {**params, "holdMs": 7001}, error="INVALID_ARGUMENT") + request = {"id": "held-click", "method": "click", "params": params} + connection.send(request) + wait_for_state(state_file, lambda value: len(value["events"]) == count + 1) + if ending == "disconnect": + connection.close() + else: + if ending == "cancel": + connection.send({"type": "cancel", "id": request["id"]}) + response = connection.read() + helper.transcript.append({"request": request, "response": response}) + assert response["id"] == request["id"], response + assert response["error"]["code"] == ("NATIVE_CANCELLED" if ending == "cancel" else "NATIVE_TIMEOUT"), response + state = wait_for_state(state_file, lambda value: len(value["events"]) >= count + 2) + assert [event["type"] for event in state["events"][count:]] == [1, 2], state + helper.call("click", {**window, **point}) + state = wait_for_state(state_file, lambda value: len(value["events"]) >= count + 4) + assert [event["type"] for event in state["events"][count:]] == [1, 2, 1, 2], state + finally: + connection.close() + connection = Connection(socket_path, key, maxClickHoldMs=0) + try: + app = connection.call("getApp", {"selector": str(observed["pid"])}) + connection.call("getState", {"appHandle": app["appHandle"], "windowId": window["windowId"], + "maxElements": 2000, "maxDepth": 25}) + count = len(state["events"]) + params = {**window, **point, "appHandle": app["appHandle"]} + connection.call("click", {**params, "holdMs": 1}, error="INVALID_ARGUMENT") + connection.call("click", {**params, "holdMs": 0}) + state = wait_for_state(state_file, lambda value: len(value["events"]) >= count + 2) + assert [event["type"] for event in state["events"][count:]] == [1, 2], state + finally: + connection.close() + return state + + def main(): parser = argparse.ArgumentParser() parser.add_argument("--helper", type=Path, required=True) parser.add_argument("--artifacts", type=Path) + parser.add_argument("--click-only", action="store_true") args = parser.parse_args() transcript = [] helper = None @@ -61,7 +136,7 @@ def main(): info = {"CFBundleName": "Capabilities Fixture", "CFBundleDisplayName": "Capabilities Fixture", "CFBundleIdentifier": "test.dsh.cua." + uuid.uuid4().hex, "CFBundleExecutable": "CapabilitiesFixture", "CFBundlePackageType": "APPL", - "DSHFixtureStatePath": str(state_file)} + "DSHFixtureStatePath": str(state_file), "DSHFixtureBackground": args.click_only} (application / "Contents/Info.plist").write_bytes(plistlib.dumps(info)) subprocess.run(["swiftc", str(ROOT / "tests/CapabilitiesFixture.swift"), "-o", str(executable)], check=True) try: @@ -79,45 +154,50 @@ def main(): app = call("launchApp", owner) pid = app["pid"] wait_for_state(state_file, lambda value: value["pid"] == pid and value["windowId"] > 0) - call("activate", owner) - state = wait_for_state(state_file, lambda value: value["pid"] == pid and value["active"] and value["keyWindow"]) - assert call("findApps", {"query": str(application), "maxAppMatches": 20})[0]["pid"] == pid - observed = call("getState", {**owner, "maxElements": 2000, "maxDepth": 25}) - field = next(item for item in observed["elements"] if item["role"] == "AXTextArea") - assert "valueWritable=true" in observed["tree"] and "selectionWritable=true" in observed["tree"], observed["tree"] - target = {**owner, "snapshotId": observed["snapshotId"], "elementIndex": field["index"]} - call("selectText", {**target, "text": "target"}, "AMBIGUOUS_TEXT") - call("selectText", {**target, "text": "target", "prefix": "第二😀 "}) - selected = wait_for_state(state_file, lambda value: value["selection"] == [17, 6]) - assert selected["text"] == "第一😀 target,第二😀 target.", selected - call("paste", {**owner, "text": "替换\n第二行\t保留", "format": "text", "maxClipboardBytes": 33554432, "pasteReadTimeoutMs": 1000, "maxElements": 2000, "maxDepth": 25}) - state = wait_for_state(state_file, lambda value: value["text"] == "第一😀 target,第二😀 替换\n第二行\t保留.") - assert state["pasteAttempts"] == [True], state - observed = call("getState", {**owner, "maxElements": 2000, "maxDepth": 25}) - field = next(item for item in observed["elements"] if item["role"] == "AXTextArea") - target = {**owner, "snapshotId": observed["snapshotId"], "elementIndex": field["index"]} - call("selectText", {**target, "text": state["text"]}) - html = '

Heading

Bold and link

' - call("paste", {**owner, "text": html, "format": "html", "maxClipboardBytes": 33554432, "pasteReadTimeoutMs": 1000, "maxElements": 2000, "maxDepth": 25}) - state = wait_for_state(state_file, lambda value: any(run.get("link") == "https://example.com/" for run in value["runs"])) - assert any(run["bold"] and "Bold" in run["text"] for run in state["runs"]), state + if not args.click_only: + call("activate", owner) + state = wait_for_state(state_file, lambda value: value["pid"] == pid and value["active"] and value["keyWindow"]) + assert call("findApps", {"query": str(application), "maxAppMatches": 20})[0]["pid"] == pid + observed = call("getState", {**owner, "maxElements": 2000, "maxDepth": 25}) + field = next(item for item in observed["elements"] if item["role"] == "AXTextArea") + assert "valueWritable=true" in observed["tree"] and "selectionWritable=true" in observed["tree"], observed["tree"] + target = {**owner, "snapshotId": observed["snapshotId"], "elementIndex": field["index"]} + call("selectText", {**target, "text": "target"}, "AMBIGUOUS_TEXT") + call("selectText", {**target, "text": "target", "prefix": "第二😀 "}) + selected = wait_for_state(state_file, lambda value: value["selection"] == [17, 6]) + assert selected["text"] == "第一😀 target,第二😀 target.", selected + call("paste", {**owner, "text": "替换\n第二行\t保留", "format": "text", "maxClipboardBytes": 33554432, "pasteReadTimeoutMs": 1000, "maxElements": 2000, "maxDepth": 25}) + state = wait_for_state(state_file, lambda value: value["text"] == "第一😀 target,第二😀 替换\n第二行\t保留.") + assert state["pasteAttempts"] == [True], state + observed = call("getState", {**owner, "maxElements": 2000, "maxDepth": 25}) + field = next(item for item in observed["elements"] if item["role"] == "AXTextArea") + target = {**owner, "snapshotId": observed["snapshotId"], "elementIndex": field["index"]} + call("selectText", {**target, "text": state["text"]}) + html = '

Heading

Bold and link

' + call("paste", {**owner, "text": html, "format": "html", "maxClipboardBytes": 33554432, "pasteReadTimeoutMs": 1000, "maxElements": 2000, "maxDepth": 25}) + state = wait_for_state(state_file, lambda value: any(run.get("link") == "https://example.com/" for run in value["runs"])) + assert any(run["bold"] and "Bold" in run["text"] for run in state["runs"]), state + state = json.loads(state_file.read_text()) pad = state["pad"] point = {"x": pad[0] + 80, "y": pad[1] + 70} window = {**owner, "windowId": state["windowId"]} - call("click", {**window, **point, "mouseButton": "middle", "modifiers": ["shift"]}) - state = wait_for_state(state_file, lambda value: any(event["type"] == 26 and event.get("button") == 2 and event["shift"] for event in value["events"])) - call("drag", {**window, "fromX": point["x"], "fromY": point["y"], "toX": point["x"] + 80, - "toY": point["y"] + 20, "durationMs": 200, "steps": 10, "mouseButton": "middle", "modifiers": ["shift"]}) - state = wait_for_state(state_file, lambda value: any(event["type"] == 27 and event.get("button") == 2 and event["shift"] for event in value["events"])) - call("pressKey", {**owner, "key": "KP_0"}) - call("pressKey", {**owner, "key": "KP_Enter"}) - state = wait_for_state(state_file, lambda value: {82, 76}.issubset({event.get("keyCode") for event in value["events"]})) + if not args.click_only: + call("click", {**window, **point, "mouseButton": "middle", "modifiers": ["shift"]}) + state = wait_for_state(state_file, lambda value: any(event["type"] == 26 and event.get("button") == 2 and event["shift"] for event in value["events"])) + call("drag", {**window, "fromX": point["x"], "fromY": point["y"], "toX": point["x"] + 80, + "toY": point["y"] + 20, "durationMs": 200, "steps": 10, "mouseButton": "middle", "modifiers": ["shift"]}) + state = wait_for_state(state_file, lambda value: any(event["type"] == 27 and event.get("button") == 2 and event["shift"] for event in value["events"])) + call("pressKey", {**owner, "key": "KP_0"}) + call("pressKey", {**owner, "key": "KP_Enter"}) + state = wait_for_state(state_file, lambda value: {82, 76}.issubset({event.get("keyCode") for event in value["events"]})) + state = check_click_holds(helper, owner, window, point, state_file) if args.artifacts: args.artifacts.mkdir(parents=True, exist_ok=True) shot = call("screenshot", window) (args.artifacts / "capabilities.png").write_bytes(base64.b64decode(shot["data"])) (args.artifacts / "state.json").write_text(json.dumps(state, ensure_ascii=False, indent=2)) - print("PASS: stopped-app discovery and launch, repeated Unicode selection, plain/HTML paste, middle/modifier drag and keypad identity") + print("PASS: click holds and cancellation/timeout/disconnect release" if args.click_only else + "PASS: stopped-app launch, Unicode selection, plain/HTML paste, middle/modifier drag, keypad identity and click holds") finally: if pid is None and state_file.exists(): pid = json.loads(state_file.read_text())["pid"] diff --git a/packages/experimental/cua-macos/native/tests/native_test_support.py b/packages/experimental/cua-macos/native/tests/native_test_support.py index 61d1b6aa40..36732a2289 100644 --- a/packages/experimental/cua-macos/native/tests/native_test_support.py +++ b/packages/experimental/cua-macos/native/tests/native_test_support.py @@ -101,7 +101,11 @@ class NativeHelper: def stop(self): try: - stop_group(self.process) + self.process.stdin.close() + try: + self.process.wait(timeout=5) + except subprocess.TimeoutExpired: + stop_group(self.process) finally: self.process.stdin.close() self.process.stdout.close() diff --git a/packages/experimental/cua-macos/native/tests/shared_helper_e2e.py b/packages/experimental/cua-macos/native/tests/shared_helper_e2e.py index c624fc5b2f..ad71ec72b2 100644 --- a/packages/experimental/cua-macos/native/tests/shared_helper_e2e.py +++ b/packages/experimental/cua-macos/native/tests/shared_helper_e2e.py @@ -23,7 +23,7 @@ from native_test_support import BINARY, ROOT, line, stop CONFIG = {"maxRequestBytes": 1024 * 1024, "maxResponseBytes": 32 * 1024 * 1024, - "axTimeoutMs": 3000, "textEventIntervalMs": 0.25, "screenshotTimeoutMs": 5000, + "axTimeoutMs": 3000, "textEventIntervalMs": 0.25, "maxClickHoldMs": 5000, "screenshotTimeoutMs": 5000, "timeoutMs": 30000, "graceMs": 3000} diff --git a/packages/experimental/cua-macos/native/tests/swift/ClickReleaseTests.swift b/packages/experimental/cua-macos/native/tests/swift/ClickReleaseTests.swift new file mode 100644 index 0000000000..3d4ea96e1a --- /dev/null +++ b/packages/experimental/cua-macos/native/tests/swift/ClickReleaseTests.swift @@ -0,0 +1,38 @@ +// Record button transitions without posting input or requiring desktop permissions. +import Testing +@testable import CuaNative + +@Test @MainActor func completedClickReleasesExactlyOnce() async throws { + var events: [String] = [] + try await WindowPointerInput.postPair(downInterval: .zero, checkCancellation: {}, + down: { events.append("down") }, up: { events.append("up") }) + #expect(events == ["down", "up"]) +} + +@Test @MainActor func cancelledHeldClickReleasesExactlyOnce() async throws { + var events: [String] = [] + let (pressed, signal) = AsyncStream.makeStream() + let operation = Task { @MainActor in + defer { signal.finish() } + try await WindowPointerInput.postPair(downInterval: .seconds(60), checkCancellation: { try Task.checkCancellation() }, + down: { events.append("down"); signal.yield(()) }, up: { events.append("up") }) + } + var iterator = pressed.makeAsyncIterator() + let observed = await iterator.next() + operation.cancel() + let result = await operation.result + #expect(observed != nil) + if case .failure(let error) = result { #expect(error is CancellationError) } + else { Issue.record("Cancelled click completed successfully") } + #expect(events == ["down", "up"]) +} + +@Test @MainActor func cancellationBeforeClickDoesNotPostInput() async { + var events: [String] = [] + do { + try await WindowPointerInput.postPair(downInterval: .zero, checkCancellation: { throw CancellationError() }, + down: { events.append("down") }, up: { events.append("up") }) + Issue.record("Cancelled click was admitted") + } catch { #expect(error is CancellationError) } + #expect(events.isEmpty) +} diff --git a/packages/experimental/cua-macos/native/tests/swift/SharedHelperTests.swift b/packages/experimental/cua-macos/native/tests/swift/SharedHelperTests.swift index 4dc70ccb93..b193612e31 100644 --- a/packages/experimental/cua-macos/native/tests/swift/SharedHelperTests.swift +++ b/packages/experimental/cua-macos/native/tests/swift/SharedHelperTests.swift @@ -9,7 +9,7 @@ private let runtimeKey = String(repeating: "a", count: 64) private func hello(overrides: [String: JSONValue] = [:]) throws -> Data { var config: [String: JSONValue] = [ "maxRequestBytes": .number(4096), "maxResponseBytes": .number(8192), "axTimeoutMs": .number(250), - "textEventIntervalMs": .number(0.5), "screenshotTimeoutMs": .number(2000), + "textEventIntervalMs": .number(0.5), "maxClickHoldMs": .number(7000), "screenshotTimeoutMs": .number(2000), "timeoutMs": .number(8000), "graceMs": .number(500), ] config.merge(overrides) { _, value in value } @@ -24,6 +24,7 @@ private func hello(overrides: [String: JSONValue] = [:]) throws -> Data { #expect(configuration.maxResponseBytes == 8192) #expect(configuration.axTimeoutSeconds == 0.25) #expect(configuration.textEventIntervalSeconds == 0.0005) + #expect(configuration.maxClickHoldMs == 7000) #expect(configuration.screenshotTimeoutSeconds == 2) #expect(configuration.timeoutSeconds == 8) #expect(configuration.shutdownGraceSeconds == 0.5) @@ -32,11 +33,22 @@ private func hello(overrides: [String: JSONValue] = [:]) throws -> Data { } } +@Test func resolvesClickHoldLimitFromHelperArguments() throws { + #expect(try HelperConfiguration(arguments: []).maxClickHoldMs == 5000) + for limit in [0, 7000] { + #expect(try HelperConfiguration(arguments: ["--max-click-hold-ms", String(limit)]).maxClickHoldMs == limit) + } + for invalid in ["-1", "0.5", "2147483648", "nan"] { + #expect(throws: NativeFailure.self) { try HelperConfiguration(arguments: ["--max-click-hold-ms", invalid]) } + } +} + @Test(arguments: [ ("maxRequestBytes", JSONValue.number(1023)), ("maxResponseBytes", .number(1.5)), ("axTimeoutMs", .number(0)), ("textEventIntervalMs", .number(-1)), ("screenshotTimeoutMs", .number(0)), ("timeoutMs", .string("8000")), ("graceMs", .number(-1)), - ("unexpected", .bool(true)), + ("maxClickHoldMs", .number(-1)), ("maxClickHoldMs", .number(0.5)), + ("maxClickHoldMs", .number(2147483648)), ("unexpected", .bool(true)), ]) func refusesInvalidSharedConfiguration(_ name: String, _ value: JSONValue) throws { #expect(throws: NativeFailure.self) { try HelperConfiguration(hello: hello(overrides: [name: value]), runtimeKey: runtimeKey) } diff --git a/packages/experimental/cua-macos/native/tests/swift/WindowCaptureTests.swift b/packages/experimental/cua-macos/native/tests/swift/WindowCaptureTests.swift index c7607ac556..da0b2a8546 100644 --- a/packages/experimental/cua-macos/native/tests/swift/WindowCaptureTests.swift +++ b/packages/experimental/cua-macos/native/tests/swift/WindowCaptureTests.swift @@ -31,7 +31,7 @@ private final class HeldCapture: @unchecked Sendable { let capture = WindowCapture { _, _, _, _, finish in source.begin(finish) } let scheduler = RequestScheduler() let cancellation = CancellationLatch() - let worker = AXWorker(cancellation: cancellation, axTimeout: 1, textEventInterval: 0) + let worker = AXWorker(cancellation: cancellation, axTimeout: 1, textEventInterval: 0, maxClickHoldMs: 5000) let target = CaptureTarget(appHandle: "fixture", process: ProcessIdentity(pid: 1, startedSeconds: 1, startedMicroseconds: 0), window: WindowInfo(windowID: 1, pid: 1, appName: "fixture", title: "fixture", bounds: WindowBounds(x: 0, y: 0, width: 1, height: 1), isOnScreen: true, onCurrentSpace: true)) diff --git a/packages/experimental/cua-macos/src/guidance.ts b/packages/experimental/cua-macos/src/guidance.ts index 742783330a..6de6ce0565 100644 --- a/packages/experimental/cua-macos/src/guidance.ts +++ b/packages/experimental/cua-macos/src/guidance.ts @@ -28,7 +28,7 @@ App and Window methods: - getScreenshot(options?: { emit?: boolean }) -> CuaScreenshot - getAXStateAndScreenshot(options?) -> { state: string, screenshot: CuaScreenshot } - hover(target: number | [x, y], options?: { relative?: boolean }) -> { dispatched, verified, hoverRoute, systemPointerMoved } -- click(target: number | [x, y], options?: { mouseButton?: "left" | "right" | "middle", clickCount?: 1 | 2, modifiers?: Array<"command" | "control" | "option" | "shift" | "fn">, relative?: boolean }) -> action result +- click(target: number | [x, y], options?: { mouseButton?: "left" | "right" | "middle", clickCount?: 1 | 2, holdMs?: number, modifiers?: Array<"command" | "control" | "option" | "shift" | "fn">, relative?: boolean }) -> action result - setValue(elementIndex: number, value: string) -> action result - selectText(elementIndex: number, text: string, options?: { prefix?: string, suffix?: string, selectionType?: "text" | "cursor_before" | "cursor_after" }) -> action result - performSecondaryAction(elementIndex: number, action: string) -> action result @@ -41,4 +41,4 @@ Await operations sequentially. After input, read fresh state to verify the effec Hover first uses advertised AX alternate UI without moving the system cursor, preserving revealed descendants until the pointer target leaves their region. When unavailable, it moves the visible system cursor without pressing a button; that route requires the target point to be unobscured in the foreground app. FOREGROUND_REQUIRED requires explicit activation or raising and a fresh observation before retrying. Returning, AX reads and screenshots do not restore the previous point. Wait for the application's hover delay, observe, then hover into the revealed controls before clicking. Use successive hover calls for intermediate points. The result reports hoverRoute and systemPointerMoved. Other input or application changes can interrupt the interaction; app objects do not reserve applications between calls. -Use selectText only on controls advertising selectionWritable. It selects a unique literal occurrence, or places the cursor before/after it; prefix and suffix must be immediately adjacent. AMBIGUOUS_TEXT requires more context; TEXT_NOT_FOUND or SELECTION_CHANGED requires a fresh observation before deciding on input. Left clicks support single/double clicks; right and middle clicks support single clicks. Modified clicks use pointer input. Keypad names such as KP_0 and KP_Enter retain their physical key identity. Window objects expose observation and pointer/AX actions; keyboard input remains app-scoped and follows current focus. Scroll deltas are integer lines from -10 to 10; positive values reveal content above or left. Paste delivers plain text or HTML through the application's enabled standard Paste menu command. Paste follows the target app's focused text input. Foreground requirements vary by application; for example, ChatGPT/Codex was observed to accept background Paste without reading clipboard data. PASTE_NOT_CONSUMED means the command was dispatched but no clipboard read was observed before the configured deadline. Inspect the target contents, explicitly activate the app and focus the intended input, then observe again before deciding whether to retry; a delayed paste may still run. Original clipboard formats are restored if the clipboard still belongs to this paste; a later copy is preserved. Unsupported Paste commands fail without typing fallback. CLIPBOARD_CHANGED means another writer intervened; observe before deciding on further input. Browser applications can be controlled with getApp like other native apps; browser tabs are unavailable. Installed-app search covers standard macOS application directories; use an absolute application path for another location. Launch does not restore a window or document, and cancellation may leave an application already started. The execution host must install the object API and own variable persistence; this plugin does not preload a cua global. In persistent PTC, use the host-provided initialization snippet below once. Its installation-owned file URL works independently of the project directory. Retain app objects between calls. Observation methods emit by default and also return their value. Use void await app.getAXState() to avoid a duplicate REPL completion, or pass emit:false and print the returned text once. End screenshot calls with void await app.getScreenshot() or void await app.getAXStateAndScreenshot() so image bytes are not serialized as the program completion value.` +Use selectText only on controls advertising selectionWritable. It selects a unique literal occurrence, or places the cursor before/after it; prefix and suffix must be immediately adjacent. AMBIGUOUS_TEXT requires more context; TEXT_NOT_FOUND or SELECTION_CHANGED requires a fresh observation before deciding on input. Left clicks support single/double clicks; right and middle clicks support single clicks. Modified clicks use pointer input. Optional holdMs is a nonnegative integer in milliseconds for each press, including each press of a double-click. Zero releases without an additional hold. The configured maximum is stated below. It uses pointer input even for AX targets; omission preserves ordinary clicks. The operation deadline still applies, and cancellation or timeout releases the held button. Keypad names such as KP_0 and KP_Enter retain their physical key identity. Window objects expose observation and pointer/AX actions; keyboard input remains app-scoped and follows current focus. Scroll deltas are integer lines from -10 to 10; positive values reveal content above or left. Paste delivers plain text or HTML through the application's enabled standard Paste menu command. Paste follows the target app's focused text input. Foreground requirements vary by application; for example, ChatGPT/Codex was observed to accept background Paste without reading clipboard data. PASTE_NOT_CONSUMED means the command was dispatched but no clipboard read was observed before the configured deadline. Inspect the target contents, explicitly activate the app and focus the intended input, then observe again before deciding whether to retry; a delayed paste may still run. Original clipboard formats are restored if the clipboard still belongs to this paste; a later copy is preserved. Unsupported Paste commands fail without typing fallback. CLIPBOARD_CHANGED means another writer intervened; observe before deciding on further input. Browser applications can be controlled with getApp like other native apps; browser tabs are unavailable. Installed-app search covers standard macOS application directories; use an absolute application path for another location. Launch does not restore a window or document, and cancellation may leave an application already started. The execution host must install the object API and own variable persistence; this plugin does not preload a cua global. In persistent PTC, use the host-provided initialization snippet below once. Its installation-owned file URL works independently of the project directory. Retain app objects between calls. Observation methods emit by default and also return their value. Use void await app.getAXState() to avoid a duplicate REPL completion, or pass emit:false and print the returned text once. End screenshot calls with void await app.getScreenshot() or void await app.getAXStateAndScreenshot() so image bytes are not serialized as the program completion value.` diff --git a/packages/experimental/cua-macos/src/shared-native.ts b/packages/experimental/cua-macos/src/shared-native.ts index 3f59c02b93..c1b141a98e 100644 --- a/packages/experimental/cua-macos/src/shared-native.ts +++ b/packages/experimental/cua-macos/src/shared-native.ts @@ -205,9 +205,10 @@ class SharedNativeChannel implements NativeChannel { else ended.resolve() }) socket.once('connect', () => { - const { maxRequestBytes, maxResponseBytes, axTimeoutMs, textEventIntervalMs, screenshotTimeoutMs, timeoutMs, graceMs } = this.config + const { maxRequestBytes, maxResponseBytes, axTimeoutMs, textEventIntervalMs, maxClickHoldMs, + screenshotTimeoutMs, timeoutMs, graceMs } = this.config socket.write(JSON.stringify({ type: 'hello', protocol: 1, runtimeKey, - config: { maxRequestBytes, maxResponseBytes, axTimeoutMs, textEventIntervalMs, screenshotTimeoutMs, timeoutMs, graceMs } }) + '\n') + config: { maxRequestBytes, maxResponseBytes, axTimeoutMs, textEventIntervalMs, maxClickHoldMs, screenshotTimeoutMs, timeoutMs, graceMs } }) + '\n') }) const receive = (chunk: Buffer): void => { const newline = chunk.indexOf(10) diff --git a/packages/experimental/cua-macos/tests/loader-composition.spec.ts b/packages/experimental/cua-macos/tests/loader-composition.spec.ts index 289a47a658..f90bb99620 100644 --- a/packages/experimental/cua-macos/tests/loader-composition.spec.ts +++ b/packages/experimental/cua-macos/tests/loader-composition.spec.ts @@ -28,7 +28,7 @@ const calls = [ { method: 'getCapabilities', params: {} }, { method: 'getApp', params: { selector: 'test.fixture' } }, { method: 'getState', params: { appHandle: 'fixture-app', windowId: 7 } }, - { method: 'click', params: { appHandle: 'fixture-app', snapshotId: 'fixture-snapshot-1', elementIndex: 0 } }, + { method: 'click', params: { appHandle: 'fixture-app', snapshotId: 'fixture-snapshot-1', elementIndex: 0, holdMs: 800 } }, { method: 'getState', params: { appHandle: 'fixture-app', windowId: 7 } }, { method: 'screenshot', params: { appHandle: 'fixture-app', windowId: 7 } }, ] @@ -111,6 +111,7 @@ it.skipIf(process.platform === 'win32')('shares one application between retained expect(toolMessages.filter(message => message.isError)).toEqual([]) expect(JSON.stringify(model.requests[0])).toContain('getCapabilities') expect(JSON.stringify(model.requests[0])).toContain('appHandle') + expect(JSON.stringify(model.requests[0])).toContain('Maximum click holdMs: 5000 ms.') expect(JSON.stringify(model.requests[0])).not.toContain('preloaded') expect(JSON.stringify(model.requests[5]?.messages)).toContain('Count: 1') const image = toolMessages.at(-1)?.content.find(block => block.type === 'image') @@ -128,6 +129,8 @@ it.skipIf(process.platform === 'win32')('shares one application between retained expect(events.filter(event => event.event === 'call').map(event => event.requestId)).toEqual(toolCalls.map(event => event.data.callId)) expect(events.find(event => event.event === 'state')?.count).toBe(1) expect(events.find(event => event.method === 'getState')?.params).toMatchObject({ maxElements: 2000, maxDepth: 25 }) + expect(events.find(event => event.method === 'click')?.params).toMatchObject({ holdMs: 800 }) + expect(toolCalls[3]?.data.arguments).toBe(JSON.stringify(calls[3])) const direct = await context.tools.execute({ agent, signal: new AbortController().signal, callId: ToolCallId('programmatic-window'), name: 'cua', arguments: { method: 'screenshot', params: { appHandle: 'fixture-app', windowId: 7 } }, diff --git a/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts b/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts index efef014ef3..5c5d68c908 100644 --- a/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts +++ b/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts @@ -51,11 +51,17 @@ vi.mock('@deepseek-ai/dsh-cua/native-client', async importOriginal => ({ const config = { agentPreset: false, helperPath: resolve('fixture-helper'), buildTimeoutMs: 300_000, timeoutMs: 10_000, connectTimeoutMs: 5000, screenshotTimeoutMs: 5000, axTimeoutMs: 3000, maxRequestBytes: 1024, maxResponseBytes: 4096, maxClipboardBytes: 8192, pasteReadTimeoutMs: 1000, maxAppMatches: 20, - graceMs: 10, textEventIntervalMs: 8, maxElements: 64, maxDepth: 8, dragDurationMs: 200, dragSteps: 10, + graceMs: 10, textEventIntervalMs: 8, maxClickHoldMs: 7000, maxElements: 64, maxDepth: 8, dragDurationMs: 200, dragSteps: 10, } satisfies Omit const { helperPath: _helperPath, ...automaticConfig } = config +it('defaults the explicit click hold limit to five seconds and accepts a configured zero or larger limit', () => { + expect(NativeProvider.Config({}).maxClickHoldMs).toBe(5000) + for (const maxClickHoldMs of [0, 7000]) expect(NativeProvider.Config({ maxClickHoldMs }).maxClickHoldMs).toBe(maxClickHoldMs) + for (const maxClickHoldMs of [-1, 0.5, 2 ** 31]) expect(() => NativeProvider.Config({ maxClickHoldMs })).toThrow() +}) + class ImageAdapter extends LlmAdapter { override resolveModel(provider: string, model: string): Promise { return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text', 'image'] }) diff --git a/packages/experimental/cua-macos/tests/shared-native.spec.ts b/packages/experimental/cua-macos/tests/shared-native.spec.ts index af6587ac65..3fedad58e1 100644 --- a/packages/experimental/cua-macos/tests/shared-native.spec.ts +++ b/packages/experimental/cua-macos/tests/shared-native.spec.ts @@ -131,7 +131,7 @@ async function fixture(options: { const config: NativeClientConfig = { helperPath, cwd: root, connectTimeoutMs: options.connectTimeoutMs ?? 5000, screenshotTimeoutMs: 5000, timeoutMs: 30_000, graceMs: options.graceMs ?? 1000, axTimeoutMs: 3000, - maxRequestBytes: 65536, maxResponseBytes: 1048576, textEventIntervalMs: 0.5, + maxRequestBytes: 65536, maxResponseBytes: 1048576, textEventIntervalMs: 0.5, maxClickHoldMs: 7000, } function open(): NativeChannel { const channel = createSharedNativeChannel({ subprocess: { spawn } }, config) @@ -188,7 +188,7 @@ describe.skipIf(process.platform === 'win32')('shared native Unix connection', ( for (const handle of f.handles) expect(handle.terminate).not.toHaveBeenCalled() expect(f.hellos).toEqual([expect.objectContaining({ type: 'hello', protocol: 1, config: { axTimeoutMs: 3000, timeoutMs: 30000, screenshotTimeoutMs: 5000, graceMs: 1000, - maxRequestBytes: 65536, maxResponseBytes: 1048576, textEventIntervalMs: 0.5, + maxRequestBytes: 65536, maxResponseBytes: 1048576, textEventIntervalMs: 0.5, maxClickHoldMs: 7000, } }), expect.anything()]) }) diff --git a/packages/experimental/cua/README.i18n.yaml b/packages/experimental/cua/README.i18n.yaml index 48e9b2c525..e5ee2a0f75 100644 --- a/packages/experimental/cua/README.i18n.yaml +++ b/packages/experimental/cua/README.i18n.yaml @@ -15,8 +15,8 @@ en: 4081cf444a77bf20 zh: def91d58969ec281 /deepseek-ai-dsh-cua/use-this-package: - en: e230429b9e15efd8 - zh: 10dcb9ba5194dc76 + en: 577e1b18dd220c73 + zh: bde0692c6695d749 /deepseek-ai-dsh-cua/understand-the-implementation: en: 751fe580f79dfaae zh: 3811db65f634ef5a diff --git a/packages/experimental/cua/README.md b/packages/experimental/cua/README.md index 866aa27baa..11cbd47457 100644 --- a/packages/experimental/cua/README.md +++ b/packages/experimental/cua/README.md @@ -39,6 +39,8 @@ Application consent precedes launch, observation, capture, and input. Session ac Actions return `dispatched:true, verified:false`; read fresh state to verify the result. Coordinate actions default to pixels in the latest returned screenshot; `relative: true` accepts integers from 0 to 1000 after a successful window observation. The Host resolves attachment normalization and the selected model route’s image projection before returning the screenshot, then converts input to native window points. Resizing invalidates the screenshot mapping; `SCREENSHOT_REQUIRED` requires a new screenshot for absolute coordinates. Resizing rejects old coordinates with `WINDOW_CHANGED`; movement alone preserves window-local coordinates. +`click(target, { holdMs: 800 })` holds the mouse button for 800 milliseconds before release. `holdMs` accepts nonnegative integers and applies to each press of a double-click; zero releases without an additional hold. The provider config `maxClickHoldMs` limits explicit holds and defaults to 5000 ms. Explicit holds use pointer input for both AX indices and coordinates; omission preserves ordinary click behavior. The operation deadline includes the hold, and cancellation or timeout releases the button. Received input cannot be undone; forced helper termination cannot guarantee release. + ----- diff --git a/packages/experimental/cua/README.zh.md b/packages/experimental/cua/README.zh.md index be9d9dc314..3b58f03187 100644 --- a/packages/experimental/cua/README.zh.md +++ b/packages/experimental/cua/README.zh.md @@ -39,6 +39,8 @@ kind: "package-library" 动作返回 `dispatched:true, verified:false`;请读取新状态以验证结果。坐标操作默认使用最近返回截图中的像素;成功观察窗口后,传入 `relative: true` 可使用 0~1000 的整数比例。Host 在返回截图前解析附件归一化和当前模型的图片缩放规则,再将输入换算为原生窗口 point。窗口尺寸变化会使截图映射失效,绝对坐标遇到 `SCREENSHOT_REQUIRED` 时需要重新截图。窗口缩放后以 `WINDOW_CHANGED` 拒绝旧坐标;单纯移动窗口不改变窗口内坐标。 +`click(target, { holdMs: 800 })` 按住鼠标按钮 800 毫秒后松开。`holdMs` 接受非负整数,双击时应用于每次按下;0 表示按下后立即松开,不额外等待。提供方配置 `maxClickHoldMs` 限制显式按住时长,默认 5000 毫秒。指定时长后,AX 索引和坐标目标都使用鼠标事件;省略时保留普通点击行为。按住时间计入操作超时,取消或超时会松开按钮。已接收的输入无法撤销;辅助进程被强制终止时无法保证松开。 + ----- diff --git a/packages/experimental/cua/src/api.ts b/packages/experimental/cua/src/api.ts index 4f4126f167..9a3dfb7a12 100644 --- a/packages/experimental/cua/src/api.ts +++ b/packages/experimental/cua/src/api.ts @@ -127,14 +127,15 @@ export function createCuaApi(dispatch: CuaDispatch, sink?: CuaObservationSink): const clickCount = options?.clickCount ?? 1 if (mouseButton !== 'left' && clickCount !== 1) throw new Error('Only left double-click is supported') const modifiers = options?.modifiers === undefined ? {} : { modifiers: [...options.modifiers] } + const hold = options?.holdMs === undefined ? {} : { holdMs: options.holdMs } const relative = options?.relative === undefined ? {} : { relative: options.relative } if (typeof target === 'number') { - return action('click', { ...snapshotParams(), ...modifiers, ...relative, elementIndex: target, + return action('click', { ...snapshotParams(), ...modifiers, ...hold, ...relative, elementIndex: target, ...mouseButton === 'left' ? {} : { mouseButton }, ...clickCount === 1 ? {} : { clickCount }, }) } const [x, y] = target - return action('click', { ...snapshotParams(), windowId: windowId(), x, y, mouseButton, clickCount, ...modifiers, ...relative }) + return action('click', { ...snapshotParams(), windowId: windowId(), x, y, mouseButton, clickCount, ...modifiers, ...hold, ...relative }) }, async setValue(index, value) { return action('setValue', { ...snapshotParams(), elementIndex: index, value }) }, async selectText(index, text, options) { diff --git a/packages/experimental/cua/src/index.ts b/packages/experimental/cua/src/index.ts index 1e70320792..708830bf2c 100644 --- a/packages/experimental/cua/src/index.ts +++ b/packages/experimental/cua/src/index.ts @@ -64,6 +64,8 @@ export interface CuaConfig { maxRequestBytes: number /** Duration in milliseconds of one same-window drag. */ dragDurationMs: number + /** Maximum explicitly requested mouse-button hold in milliseconds; zero allows immediate release only. */ + maxClickHoldMs: number /** Number of movement segments dispatched during a drag. */ dragSteps: number /** Delay in milliseconds after each Unicode key-down or key-up event. */ @@ -94,6 +96,7 @@ export const CuaConfig = Schema.object({ pasteReadTimeoutMs: Schema.number().step(1).min(1).max(2 ** 31 - 1).default(1000), maxRequestBytes: Schema.number().step(1).min(1024).default(1024 * 1024), dragDurationMs: Schema.number().step(1).min(1).max(5000).default(400), + maxClickHoldMs: Schema.number().step(1).min(0).max(2 ** 31 - 1).default(5000), dragSteps: Schema.number().step(1).min(1).max(240).default(20), maxResponseBytes: Schema.number().step(1).min(1024).default(32 * 1024 * 1024), textEventIntervalMs: Schema.number().min(0).max(1000).default(0.25), @@ -156,11 +159,12 @@ export interface CuaBackend { */ export function installCua(ctx: Context, config: CuaConfig, backend: CuaBackend): void { const { helperPath } = backend + const guidance = `${backend.guidance}\n\nMaximum click holdMs: ${config.maxClickHoldMs} ms.` const native: NativeClientConfig = { helperPath, cwd: process.cwd(), timeoutMs: config.timeoutMs, axTimeoutMs: config.axTimeoutMs, connectTimeoutMs: config.connectTimeoutMs, screenshotTimeoutMs: config.screenshotTimeoutMs, maxRequestBytes: config.maxRequestBytes, maxResponseBytes: config.maxResponseBytes, graceMs: config.graceMs, - textEventIntervalMs: config.textEventIntervalMs, + textEventIntervalMs: config.textEventIntervalMs, maxClickHoldMs: config.maxClickHoldMs, } const entryId = ctx.fiber.entry?.options.id ctx.inject(['settings'], scope => scope.effect(() => scope.settings.configure({ auto: false }, ctx.fiber))) @@ -331,7 +335,7 @@ export function installCua(ctx: Context, config: CuaConfig, backend: CuaBackend) } const prompt: PromptSection = { name: 'computer-use:dsh-cua', order: ctx.systemPrompt.getSectionOrder('TOOL_COMPUTER_USE'), - text: () => ctx.get('ptcRuntime')?.programFormat !== 'repl' ? backend.guidance : `${backend.guidance} + text: () => ctx.get('ptcRuntime')?.programFormat !== 'repl' ? guidance : `${guidance} Persistent PTC initialization: var { connectCua } = await import(${JSON.stringify(new URL('../lib/bridge.js', import.meta.url).href)}); diff --git a/packages/experimental/cua/src/native-client.ts b/packages/experimental/cua/src/native-client.ts index a61ff18bf1..3ce7b81b67 100644 --- a/packages/experimental/cua/src/native-client.ts +++ b/packages/experimental/cua/src/native-client.ts @@ -43,6 +43,7 @@ export interface NativeClientConfig { maxResponseBytes: number graceMs: number textEventIntervalMs: number + maxClickHoldMs: number } /** One Agent's ordered connection to native operations and cleanup acknowledgement. */ diff --git a/packages/experimental/cua/src/protocol.ts b/packages/experimental/cua/src/protocol.ts index f732e1bf82..3e1d9f6450 100644 --- a/packages/experimental/cua/src/protocol.ts +++ b/packages/experimental/cua/src/protocol.ts @@ -10,6 +10,9 @@ const relativePoint = z.number().int().min(0).max(1000) const app = { appHandle: z.string().min(1) } const pointer = { mouseButton: z.enum(['left', 'right', 'middle']).optional(), modifiers: z.array(z.enum(['command', 'control', 'option', 'shift', 'fn'])).optional() } +const click = { ...pointer, clickCount: z.union([z.literal(1), z.literal(2)]).optional(), + holdMs: z.number().int().nonnegative().optional() + .describe('Milliseconds between each mouse press and release; omitted for an ordinary click.') } const window = { ...app, windowId: id } const snapshot = { ...app, snapshotId: z.string().min(1), elementIndex: z.number().int().nonnegative() } const coordinate = { ...window, snapshotId: z.string().min(1).optional(), x: point, y: point } @@ -43,9 +46,9 @@ export const Request = z.discriminatedUnion('method', [ z.object(snapshot).strict(), z.object(absoluteCoordinate).strict(), z.object(relativeCoordinate).strict(), ]) }).strict(), z.object({ method: z.literal('click'), params: z.union([ - z.object({ ...snapshot, ...pointer, clickCount: z.union([z.literal(1), z.literal(2)]).optional() }).strict(), - z.object({ ...absoluteCoordinate, ...pointer, clickCount: z.union([z.literal(1), z.literal(2)]).optional() }).strict(), - z.object({ ...relativeCoordinate, ...pointer, clickCount: z.union([z.literal(1), z.literal(2)]).optional() }).strict(), + z.object({ ...snapshot, ...click }).strict(), + z.object({ ...absoluteCoordinate, ...click }).strict(), + z.object({ ...relativeCoordinate, ...click }).strict(), ]) }).strict(), operation('setValue', { ...snapshot, value: z.string() }), operation('selectText', { ...snapshot, text: z.string().min(1), prefix: z.string().optional(), suffix: z.string().optional(), diff --git a/packages/experimental/cua/src/types.ts b/packages/experimental/cua/src/types.ts index a413854609..2ca68b96a3 100644 --- a/packages/experimental/cua/src/types.ts +++ b/packages/experimental/cua/src/types.ts @@ -67,8 +67,12 @@ export type CuaShowResult = z.output export type CuaPointerModifier = NonNullable['modifiers']>[number] /** Buttons supported by window-directed clicks and drags. */ export type CuaMouseButton = NonNullable['mouseButton']> -/** Left clicks support one or two presses; right and middle clicks support one. */ -export type CuaClickOptions = Pick, 'mouseButton' | 'clickCount' | 'modifiers'> & CuaCoordinateOptions +/** + * Left clicks support one or two presses; right and middle clicks support one. + * holdMs is a nonnegative integer in milliseconds, limited by maxClickHoldMs; zero releases immediately. + * Explicit holds use pointer input; omission preserves ordinary clicks. + */ +export type CuaClickOptions = Pick, 'mouseButton' | 'clickCount' | 'modifiers' | 'holdMs'> & CuaCoordinateOptions /** One same-window drag; omitted button is left and omitted modifiers are empty. */ export type CuaDragOptions = Pick, 'mouseButton' | 'modifiers'> & CuaCoordinateOptions /** Clipboard representation delivered through the application's Paste command. */ @@ -121,8 +125,9 @@ export interface CuaSurface { hover(target: number | readonly [number, number], options?: CuaCoordinateOptions): Promise /** * Click a current AX index or screenshot-local point. + * Cancellation or timeout releases the held button; received input cannot be undone. The operation deadline includes holdMs. * @param target - index or [x,y]. - * @param options - button, click count, modifiers and coordinate mode. + * @param options - button, click count, modifiers, optional hold duration and coordinate mode. * @returns dispatch evidence. */ click(target: number | readonly [number, number], options?: CuaClickOptions): Promise diff --git a/packages/experimental/cua/tests/api.spec.ts b/packages/experimental/cua/tests/api.spec.ts index b85e8c67ba..02eb39c05f 100644 --- a/packages/experimental/cua/tests/api.spec.ts +++ b/packages/experimental/cua/tests/api.spec.ts @@ -171,6 +171,21 @@ describe('hover across observations', () => { }) describe('pointer buttons and modifiers', () => { + it.each([0, 800])('preserves %d ms for app and window clicks at elements and coordinates', async (holdMs) => { + const f = fixture() + const app = await f.api.getApp('Editor') + const window = await app.getWindow(23) + for (const surface of [app, window]) { + for (const target of [0, [10, 20] as const]) { + await surface.click(target, { holdMs, mouseButton: 'right' }) + expect(f.dispatch.mock.calls.at(-1)?.[0]).toMatchObject({ method: 'click', params: { holdMs, mouseButton: 'right' } }) + f.dispatch.mockClear() + await expect(surface.click(target, { holdMs: -1 })).rejects.toThrow('holdMs') + expect(f.dispatch).not.toHaveBeenCalled() + } + } + }) + it('retains middle-button and modifier intent through click and drag dispatch', async () => { const f = fixture() const app = await f.api.getApp('Editor') diff --git a/packages/experimental/cua/tests/application-access.spec.ts b/packages/experimental/cua/tests/application-access.spec.ts index 9e0eac7822..74d2ddefbf 100644 --- a/packages/experimental/cua/tests/application-access.spec.ts +++ b/packages/experimental/cua/tests/application-access.spec.ts @@ -31,7 +31,7 @@ vi.mock('../src/native-client.ts', async original => ({ const unusedConnector = () => { throw new Error('Mocked native client does not connect') } const nativeConfig = { helperPath: '/fixture', cwd: '/', timeoutMs: 1000, connectTimeoutMs: 5000, screenshotTimeoutMs: 5000, axTimeoutMs: 100, - maxRequestBytes: 4096, maxResponseBytes: 4096, graceMs: 100, textEventIntervalMs: 1 } + maxRequestBytes: 4096, maxResponseBytes: 4096, graceMs: 100, textEventIntervalMs: 1, maxClickHoldMs: 5000 } const identity = ApplicationAuthorizationResult.parse({ id: 'a'.repeat(64), name: 'Test application', executablePath: '/Applications/Fixture.app/Contents/MacOS/Fixture', persistable: true }) const handle = 'fixture-app' as CuaAppHandle const signal = new AbortController().signal diff --git a/packages/experimental/cua/tests/native-client.spec.ts b/packages/experimental/cua/tests/native-client.spec.ts index 83c35d481a..c8b3dae007 100644 --- a/packages/experimental/cua/tests/native-client.spec.ts +++ b/packages/experimental/cua/tests/native-client.spec.ts @@ -42,7 +42,7 @@ function fixture(options: { helperPath: '/fixture/helper', cwd: '/fixture', axTimeoutMs: 321, timeoutMs: options.timeoutMs ?? 60_000, connectTimeoutMs: 5000, screenshotTimeoutMs: options.screenshotTimeoutMs ?? 5000, maxRequestBytes: options.maxRequestBytes ?? 1024, - maxResponseBytes: options.maxResponseBytes ?? 1024, graceMs: options.graceMs ?? 10, textEventIntervalMs: 0.5, + maxResponseBytes: options.maxResponseBytes ?? 1024, graceMs: options.graceMs ?? 10, textEventIntervalMs: 0.5, maxClickHoldMs: 5000, } const client = new NativeClient(owner, config, shared.create, options.reportDiagnostic) function finish() { done.resolve(); gone.resolve() } diff --git a/packages/experimental/cua/tests/protocol.spec.ts b/packages/experimental/cua/tests/protocol.spec.ts index a5c3977daa..fb2c17cfd2 100644 --- a/packages/experimental/cua/tests/protocol.spec.ts +++ b/packages/experimental/cua/tests/protocol.spec.ts @@ -18,6 +18,20 @@ it.each(rejectedRequests)('rejects $reason at operation admission', ({ request } expect(Request.safeParse(request).success).toBe(false) }) +it.each([ + { appHandle: 'app', snapshotId: 'snapshot', elementIndex: 0 }, + { appHandle: 'app', windowId: 23, x: 1, y: 2 }, + { appHandle: 'app', windowId: 23, x: 1, y: 2, relative: true }, +])('validates optional per-press hold duration for %j', (params) => { + for (const holdMs of [0, 1, 800, 5000, 7000]) { + const request = { method: 'click', params: { ...params, holdMs } } + expect(Request.parse(request)).toEqual(request) + } + for (const holdMs of [-1, 1.5, Infinity, NaN, '800', null]) { + expect(Request.safeParse({ method: 'click', params: { ...params, holdMs } }).success).toBe(false) + } +}) + it.each([ '', 'not base64!', 'AQ', 'AQ===', 'AQ==\n', 'A Q==', 'AQ-_', 'AB==', 'AAF=', ])('rejects noncanonical screenshot base64 %j', (data) => { diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index 1dd6bf22b9..aa66cbc151 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -913,7 +913,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionProjectionSnapshot', - declaration: 'export interface SessionProjectionSnapshot {\n readonly values: Readonly>;\n readonly state: \'idle\' | \'loading\' | \'ready\' | \'migration-required\' | \'error\';\n readonly error: RemoteFailure | null;\n}', + declaration: 'export interface SessionProjectionSnapshot {\n readonly values: Readonly>;\n readonly state: \'idle\' | \'loading\' | \'ready\' | \'migration-required\' | \'error\';\n readonly error: RemoteFailure | null;\n}', }, { name: 'SessionProviderComponent', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 51fb6586dc..ee8d6aafeb 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2362,7 +2362,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'The persisted projection cache service. Opens the `session_projcache` domain at init, checkpoints live sessions on a throttled write-behind (count/interval triggers from Config) plus three mandatory points — session creation, `turn/end`, and session disposal (the live-to-cold moment) — and serves the cached rows for a session header. Every durable write is fail-soft: failures log a warning and the cache self-heals on the next write.', methods: [ { - signature: 'cachedSnapshot( meta: SessionHeader, keys?: readonly Extract[], ): ProjectionSnapshot | undefined', + signature: 'cachedSnapshot( meta: SessionHeader, keys?: ProjectionKeySelection, ): ProjectionSnapshot | undefined', description: 'The zero-I/O listing read: whole values viewed straight from the stored rows (version-matching keys only) of the record bound to the caller\'s lifecycle. The header is the only identity witness a listing holds, so this face matches the lifecycle identity (`formatVersion`, `createdAt`, `cwd`, `isSeeded`) and not the inherited cut: within one format generation the cut is fixed at fork time, so it distinguishes no lifecycle the other fields do not, and a viewed value never seeds a fold. The view is as stale as the last durable checkpoint but never wrong and never from an unrelated log. Its `asOfSeq` is the lowest watermark among the served rows: the stored record\'s own position, which the header cannot relate to the log the caller later opens. The Session list therefore labels the block as cached, and the client lets every value the connected Session produces supersede it whatever this number says.', parameters: [{ name: 'meta', description: 'the listed session\'s header (identity witness; no log read).' }, { name: 'keys', description: 'optional projection keys required by the caller\'s audience.' }], returns: 'the viewed block, or `undefined` when no usable row exists for this lifecycle at the current Session format.', @@ -2423,17 +2423,23 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'current state, or `undefined` when the key is not registered.', }, { - signature: 'snapshot( session: Session, keys?: readonly Extract[], ): ProjectionSnapshot', + signature: 'snapshot( session: Session, keys?: ProjectionKeySelection, ): ProjectionSnapshot', description: 'One consistent cut over every registered client-visible unit for one session, read from the watermark cache (missing cells fold lazily over the in-memory log). Fully synchronous — every value and `asOfSeq` reflect the same log position. Each value passes its unit\'s `viewSchema` before leaving.', parameters: [{ name: 'session', description: 'the session whose projection values are read.' }, { name: 'keys', description: 'optional client-visible outputs; state materialization remains complete.' }], returns: 'the snapshot; `values` is empty when no selected client-visible unit is registered.', }, { - signature: 'cachedSnapshot( session: Session, keys?: readonly Extract[], ): ProjectionSnapshot | undefined', + signature: 'cachedSnapshot( session: Session, keys?: ProjectionKeySelection, ): ProjectionSnapshot | undefined', description: 'Read only already-materialized client-visible cells without folding history. Values may trail the live Session and are therefore hints, not a complete baseline. Missing cells are omitted.', parameters: [{ name: 'session', description: 'attached Session whose cached cells are inspected.' }, { name: 'keys', description: 'optional wire keys to view.' }], returns: 'the lowest common cached cut, or `undefined` when no wire cell exists.', }, + { + signature: 'summaryKeys(): ReadonlySet>', + description: 'The keys a Session summary carries: every registered client-visible key whose unit does not declare `summary: false`. The returned set is stable — the same instance on every call — until a registration or its disposal changes the key table, so a consumer may hold it across rows. Callers pass it to a key-taking read face such as `cachedSnapshot(session, summaryKeys())` or `viewCheckpoint(rows, summaryKeys())`; those faces use the set as-is.', + parameters: [], + returns: 'the current summary key set; do not mutate it.', + }, { signature: 'checkpoint(session: Session): ProjectionCheckpoint', description: 'State-level checkpoint of every persisted unit for one session, read from the watermark cache (missing cells fold lazily over the in-memory log). This is the write side of the persisted projection cache: the returned rows are the `(key → {ver, seq, val})` part of the durable `(sessionId, key, ver, seq, val)` rows. Every `val` is a DETACHED structured clone — never the live cell reference: the watermark cache is this registry\'s authoritative mutable state, and a caller reaching the live reference could corrupt every subsequent snapshot and frame through it (plain JSON by the unit contract, so the clone is total).', @@ -2447,7 +2453,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'the offset for the stored-log suffix read (`SessionHandle.read`), or `undefined` when no unit is registered (no read needed — {@link restore} would serve empty values regardless).', }, { - signature: 'viewCheckpoint( checkpoint: ProjectionCheckpoint, keys?: readonly Extract[], ): Partial', + signature: 'viewCheckpoint( checkpoint: ProjectionCheckpoint, keys?: ProjectionKeySelection, ): Partial', description: 'View a checkpoint\'s rows without any log read: for every registered client-visible unit whose row\'s `ver` matches, serve the schema-validated `view` of the schema-validated stored state; mismatched, malformed, or absent rows leave their key absent (a cold or listing consumer treats it as not-yet-available and a fuller read path refolds it). The zero-I/O rung of the read ladder — values are as stale as their rows, never wrong.', parameters: [{ name: 'checkpoint', description: 'persisted rows for one session (possibly stale or empty).' }, { name: 'keys', description: 'optional wire keys to view.' }], returns: 'whole values per key with a usable row; empty when none.', @@ -6643,7 +6649,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ProjectionDefinition', - declaration: 'export interface ProjectionDefinition {\n key: K;\n stateSchema: ZodType;\n init(header: SessionHeader, inheritedEventCount: SessionLogOffset): NoInfer;\n apply(state: NoInfer, event: SessionEvent): NoInfer;\n wire?: K extends keyof SessionProjectionMap ? {\n viewSchema: ZodType;\n view(state: NoInfer): SessionProjectionMap[K];\n } : never;\n stateVersion: number;\n}', + declaration: 'export interface ProjectionDefinition {\n key: K;\n stateSchema: ZodType;\n init(header: SessionHeader, inheritedEventCount: SessionLogOffset): NoInfer;\n apply(state: NoInfer, event: SessionEvent): NoInfer;\n wire?: K extends keyof SessionProjectionMap ? {\n viewSchema: ZodType>;\n view(state: NoInfer): SessionProjectionViewValue;\n summary?: boolean;\n } : never;\n stateVersion: number;\n}', + }, + { + name: 'ProjectionKeySelection', + declaration: 'export type ProjectionKeySelection = readonly Extract[] | ReadonlySet>;', }, { name: 'ProjectionSnapshot', @@ -7435,7 +7445,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionProjectionHints', - declaration: 'export interface SessionProjectionHints {\n readonly kind: \'cached\' | \'sequenced\';\n readonly asOfSeq: number;\n readonly values: SessionProjectionValues;\n}', + declaration: 'export interface SessionProjectionHints {\n readonly kind: \'cached\' | \'sequenced\';\n readonly asOfSeq: number;\n readonly values: Partial;\n}', }, { name: 'SessionProjectionMap', @@ -7449,6 +7459,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionProjectionStateMap', declaration: 'export interface SessionProjectionStateMap {\n}', }, + { + name: 'SessionProjectionSummaryExcluded', + declaration: 'export type SessionProjectionSummaryExcluded = T & {\n readonly [sessionProjectionSummaryExcluded]: true;\n};', + }, + { + name: 'SessionProjectionSummaryExcludedKey', + declaration: 'export type SessionProjectionSummaryExcludedKey = {\n [K in keyof SessionProjectionMap]: NonNullable extends SessionProjectionSummaryExcluded ? K : never;\n}[keyof SessionProjectionMap];', + }, + { + name: 'SessionProjectionSummaryValues', + declaration: 'export type SessionProjectionSummaryValues = Omit;', + }, { name: 'SessionProjectionsValue', declaration: 'export type SessionProjectionsValue = (SessionProjectionBaseline & {\n readonly kind: \'sequenced\';\n}) | {\n readonly kind: \'migration-required\';\n readonly values: SessionProjectionValues;\n} | null;', @@ -7465,6 +7487,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionProjectionValues', declaration: 'export type SessionProjectionValues = Partial & Readonly>;', }, + { + name: 'SessionProjectionViewValue', + declaration: 'export type SessionProjectionViewValue = T extends SessionProjectionSummaryExcluded ? Value : T;', + }, + { + name: 'SessionProjectionViewValues', + declaration: 'export type SessionProjectionViewValues = {\n [K in keyof SessionProjectionMap]: SessionProjectionViewValue;\n};', + }, { name: 'SessionPromptRequest', declaration: 'export interface SessionPromptRequest {\n readonly requestId: SessionRequestId;\n readonly sessionId: SessionId;\n readonly mode: \'queue\' | \'steer\';\n readonly content: readonly PromptContentPart[];\n readonly clientTimeZone?: string;\n}', diff --git a/packages/session/session-projection-cache/src/index.ts b/packages/session/session-projection-cache/src/index.ts index 34726fc3b8..10119d26a3 100644 --- a/packages/session/session-projection-cache/src/index.ts +++ b/packages/session/session-projection-cache/src/index.ts @@ -28,6 +28,7 @@ import type { } from '@deepseek-ai/dsh-session' import type { ProjectionCheckpoint, + ProjectionKeySelection, ProjectionSnapshot, SessionProjectionMap, } from '@deepseek-ai/dsh-session-projection' @@ -160,7 +161,7 @@ export class SessionProjectionCache extends Service { */ cachedSnapshot( meta: SessionHeader, - keys?: readonly Extract[], + keys?: ProjectionKeySelection, ): ProjectionSnapshot | undefined { const expected = lifecycleIdentityOf(meta) const record = this.requireTable().get(meta.id) @@ -199,7 +200,7 @@ export class SessionProjectionCache extends Service { */ private viewRecord( record: CheckpointRecord, - keys?: readonly Extract[], + keys?: ProjectionKeySelection, ): ProjectionSnapshot | undefined { const values = this.ctx.sessionProjections.viewCheckpoint(record.rows, keys) let asOfSeq: ProjectionSnapshot['asOfSeq'] | undefined diff --git a/packages/session/session-projection-cache/tests/cache.spec.ts b/packages/session/session-projection-cache/tests/cache.spec.ts index 87860844ee..95b4ad92c4 100644 --- a/packages/session/session-projection-cache/tests/cache.spec.ts +++ b/packages/session/session-projection-cache/tests/cache.spec.ts @@ -26,6 +26,7 @@ import SessionStore, { import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +import type { SessionProjectionSummaryExcluded } from '@deepseek-ai/dsh-session-projection/types' import Storage from '@deepseek-ai/dsh-storage' import { apply as storageJsonApply, Config as storageJsonConfig, inject as storageJsonInject, name as storageJsonName, @@ -45,12 +46,14 @@ declare module '@deepseek-ai/dsh-session-projection/types' { 'cache-test/count': number 'cache-test/secret': string 'cache-test/marks3': MarksState + 'cache-test/summary-excluded': { n: number } title: string | null } interface SessionProjectionMap { 'cache-test/marks': { marks: string[] } 'cache-test/secondary-marks': { marks: string[] } 'cache-test/marks3': { marks: string[] } + 'cache-test/summary-excluded': SessionProjectionSummaryExcluded<{ n: number }> title: string | null } } @@ -110,6 +113,16 @@ const secondaryMarksUnit = { stateVersion: 1, } satisfies ProjectionDefinition<'cache-test/secondary-marks', MarksState> +/** A summary-excluded unit: its rows must never reach the summary block. */ +const summaryExcludedUnit = { + key: 'cache-test/summary-excluded', + stateSchema: z.object({ n: z.number() }), + init: () => ({ n: 0 }), + apply: state => state, + wire: { viewSchema: z.object({ n: z.number() }), view: state => state, summary: false }, + stateVersion: 1, +} satisfies ProjectionDefinition<'cache-test/summary-excluded', { n: number }> + /** Mirrors the shipped title unit's storage face (stateVersion 1, bare-string state). */ const titleUnit = { key: 'title', @@ -809,3 +822,33 @@ describe('SessionProjectionCache cold-read seeding', () => { }, { timeout: 5_000 }) }) }) + +describe('SessionProjectionCache summary reads', () => { + it('serves the summary block from stored rows and excludes the marked unit', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-')) + roots.push(root) + await seedRecord(root, 'summary-watermark', { + 'cache-test/marks': { ver: 1, seq: SessionSeq(5), val: { marks: ['primary'] } }, + 'cache-test/summary-excluded': { ver: 1, seq: SessionSeq(1), val: { n: 3 } }, + }) + const { ctx, cache } = await harness({ root }) + ctx.sessionProjections.register(summaryExcludedUnit) + const summaryKeys = ctx.sessionProjections.summaryKeys() + + // The summary watermark follows the served rows only: the marked row sits + // at seq 1, and its absence must not drag the summary block down. + expect(cache.cachedSnapshot(headerOf(SessionId('summary-watermark')), summaryKeys)).toEqual({ + asOfSeq: 5, + values: { 'cache-test/marks': { marks: ['primary'] } }, + }) + expect(cache.cachedSnapshot(headerOf(SessionId('summary-watermark')))).toEqual({ + asOfSeq: 1, + values: { + 'cache-test/marks': { marks: ['primary'] }, + 'cache-test/summary-excluded': { n: 3 }, + }, + }) + // No record for this id: a key-subset read reports absence like the complete face. + expect(cache.cachedSnapshot(headerOf(SessionId('summary-absent')), summaryKeys)).toBeUndefined() + }) +}) diff --git a/packages/session/session-projection/README.i18n.yaml b/packages/session/session-projection/README.i18n.yaml index c6305ff877..4907213c01 100644 --- a/packages/session/session-projection/README.i18n.yaml +++ b/packages/session/session-projection/README.i18n.yaml @@ -27,8 +27,11 @@ en: 6a19c075f826b3f7 zh: c200277cb58062be /deepseek-ai-dsh-session-projection/use-this-package/persisted-checkpoints: - en: 45f1c05fb7e08180 - zh: f8d62c749899651a + en: 3bbc61a438aeabd0 + zh: bd1824f4da278764 +/deepseek-ai-dsh-session-projection/use-this-package/session-summaries: + en: bbdefde61fb3b02f + zh: c6871e05e63ff4ed /deepseek-ai-dsh-session-projection/understand-the-implementation: en: 3397f58d6f49bfda zh: efb85d382e25574d @@ -36,8 +39,8 @@ en: 8afa0fdb49898f6d zh: 6b2da34455963935 /deepseek-ai-dsh-session-projection/understand-the-implementation/source-map: - en: b0263d26c5a65d7a - zh: 2e58d3181dde901c + en: 01b1a2ad7a7cab97 + zh: bd0436807998dda6 /deepseek-ai-dsh-session-projection/understand-the-implementation/drive-and-checkpoint-flow: en: 00136880bb586403 zh: bd35f645111c068c @@ -51,8 +54,8 @@ en: 8a592dae9bc946ab zh: eca939995bb5f8e0 /deepseek-ai-dsh-session-projection/known-limitations-and-deferred-work: - en: 8fd41113832feb7c - zh: d50021b135278d77 + en: dda14db5a0fbe2fa + zh: 9675b2a39e7aea8b /deepseek-ai-dsh-session-projection/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md index 79c5e0b213..97c8b4c6ab 100644 --- a/packages/session/session-projection/README.md +++ b/packages/session/session-projection/README.md @@ -68,6 +68,14 @@ A domain that requires projected state declares `sessionProjections` as a Cordis Every unit's state is checkpointed — client-visible and host-only alike — through `checkpoint(session)`, and the sibling [session-projection-cache](../session-projection-cache/README.md) persists those checkpoints so cold reads skip full log loads. Checkpoint watermarks use `SessionSeqCursor` (`-1` for an empty log), while replay starts use `SessionLogOffset`; `restoreFloor` and `restore` implement the read recipe without conflating an existing event with a log gap. +### Session summaries + +A Session summary is every registered client view except the keys whose non-nullish `SessionProjectionMap` entry carries the `SessionProjectionSummaryExcluded` mark. `summaryKeys()` returns that set, derived from the live registrations and stable — the same instance — until a registration or its disposal changes the key table. A summary consumer passes it to the existing key-taking reads — `cachedSnapshot(session, keys)` for live cells, `viewCheckpoint(rows, keys)` for checkpoint rows, and `cachedSnapshot(header, keys)` through the persisted cache — which treat a set as already selected, so there is no summary-specific read face, no copy per read, and no second definition of the set. Because the registry derives it, a summary consumer cannot request an excluded key through the derived set; a complete face still serves an explicitly named key, which is how an opened Session keeps every value. A list row carries the summary projection and never the full projection. + +The mark decides the client view type; `wire.summary` decides the runtime key set, and omitting it means `true`. Declare `wire.summary: false` on a key whose map entry carries the mark so the two agree; the field is a plain boolean, not a type-level interlock. A nullable entry keeps `null` outside the wrapper (`SessionProjectionSummaryExcluded | null`) so the pre-first-write value stays expressible. The keys currently marked are `turnOutline`, `sessionStats`, `todos`, and `imageLimits`: an outline grows with the whole log, no list row renders the stats or todo list, and the image limits are one host-wide boot constant every Session shares. + +Summaries serve the Session list rows, which list Sessions without opening them. The `@` reference lists Sessions the same way but reads only its `title` and `subagent` label keys through the key-taking `snapshot(session, keys)` and `cachedSnapshot(header, keys)` faces. `snapshot`, `cachedSnapshot`, and `viewCheckpoint` still serve every wire key when called without a key subset, so an opened Session receives every projected value. + ----- @@ -87,7 +95,7 @@ The package is the Service Definition and drive role of a capability seam: the f | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: `SessionProjectionRegistry` service, `ProjectionDefinition`, snapshot and checkpoint machinery | -| [`src/types.ts`](src/types.ts) | The merge-extensible `SessionProjectionMap` and `SessionProjectionStateMap` type tables | +| [`src/types.ts`](src/types.ts) | The merge-extensible `SessionProjectionMap` and `SessionProjectionStateMap` type tables, plus the summary-exclusion mark and its derived view and summary types | ### Drive and checkpoint flow @@ -126,7 +134,7 @@ None; projections never assemble or send provider requests. These limits define where the projection registry needs care at scale. They are current package constraints, not a task backlog. -- **Every tail page carries every client-visible key** — there is no per-key opt-out or lazy-key request shape yet; acceptable while values are UI-scale whole states, revisit if a domain's value grows large. +- **Only a key-subset read omits keys; a tail page still carries every client-visible key and there is no lazy per-value request** — a summary omits exactly the keys whose `SessionProjectionMap` entry carries `SessionProjectionSummaryExcluded` and whose registration declares `wire.summary: false`. The opt-out is per key, not per value: no read defers one large value. - **The unit table is process-wide, so key presence is not a per-session capability signal** — a key registered by any agent preset appears in every session's snapshot; a client must read the value rather than treat an absent key as absence of the feature. - **Eager drive touches every unit per event** — cheap by construction (whole-value rule and state/view reference gates), but a hot path would justify per-unit event-type prefilters. - **Registry cells live in memory only** — a restart rebuilds by folding the log on first touch; compositions that mount `dsh-session-projection-cache` seed that fold from persisted rows instead. diff --git a/packages/session/session-projection/README.zh.md b/packages/session/session-projection/README.zh.md index cb44e32ef9..2d6673979d 100644 --- a/packages/session/session-projection/README.zh.md +++ b/packages/session/session-projection/README.zh.md @@ -68,6 +68,14 @@ const { asOfSeq, values } = ctx.sessionProjections.snapshot(session) 系统通过 `checkpoint(session)` 为每个单元的状态创建检查点,client-visible 与 host-only 一视同仁;同级包 [session-projection-cache](../session-projection-cache/README.zh.md) 持久化这些检查点,使冷读跳过全量日志加载。检查点水位使用 `SessionSeqCursor`(空日志为 `-1`),回放起点使用 `SessionLogOffset`;`restoreFloor` 与 `restore` 实现读取流程,且不会混淆已有事件与日志间隙。 +### Session 摘要 + +Session 摘要包含每个已注册客户端视图,但排除 `SessionProjectionMap` 条目(非空值)带 `SessionProjectionSummaryExcluded` 标记的 key。`summaryKeys()` 返回该集合,从实时注册派生;在注册或注销改变 key 表之前,它保持同一实例。摘要消费方把它传给既有的带 key 读取面 —— 实时 cell 用 `cachedSnapshot(session, keys)`,checkpoint 行用 `viewCheckpoint(rows, keys)`,持久化缓存用 `cachedSnapshot(header, keys)` —— 这些读取面把 Set 视为已选好的集合,因此不存在摘要专用读取面、每次读取无需复制,集合也只有一处定义。由于集合由注册表派生,摘要消费方无法通过该集合请求被排除的 key;完整读取面仍会按显式命名的 key 提供值,打开的 Session 因此保留全部取值。列表行携带的是摘要投影,绝不是全量投影。 + +标记决定客户端视图类型,`wire.summary` 决定运行时键集合,省略即 `true`。map 条目带标记的 key 应声明 `wire.summary: false` 以保持一致;该字段就是普通布尔值,而不是类型层的互锁。可空条目把 `null` 放在包装之外(`SessionProjectionSummaryExcluded | null`),以便首次写入前的值仍可表达。当前被标记的 key 是 `turnOutline`、`sessionStats`、`todos` 和 `imageLimits`:outline 随整份日志增长,列表行不渲染统计或 todo 清单,而图片上限是每个 Session 共享的同一个宿主级启动常量。 + +摘要服务于 Session 列表行,列表无需打开 Session。`@` 引用同样以列表方式枚举 Session,但只通过带 key 的 `snapshot(session, keys)` 与 `cachedSnapshot(header, keys)` 读取其 `title` 和 `subagent` 标签 key。`snapshot`、`cachedSnapshot` 和 `viewCheckpoint` 在不传 key 子集时仍返回每个 wire key,因此打开的 Session 仍会收到全部投影值。 + ----- @@ -87,7 +95,7 @@ const { asOfSeq, values } = ctx.sessionProjections.snapshot(session) | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:`SessionProjectionRegistry` 服务、`ProjectionDefinition`、快照与检查点机制 | -| [`src/types.ts`](src/types.ts) | 可合并扩展的 `SessionProjectionMap` 与 `SessionProjectionStateMap` 类型表 | +| [`src/types.ts`](src/types.ts) | 可合并扩展的 `SessionProjectionMap` 与 `SessionProjectionStateMap` 类型表,以及摘要排除标记及其派生的视图与摘要类型 | ### 驱动与检查点流程 @@ -126,7 +134,7 @@ const { asOfSeq, values } = ctx.sessionProjections.snapshot(session) 这些限制说明投影注册表在大规模下何时需要特别处理。它们是当前包约束,不是任务积压。 -- **每个尾页携带每个 client-visible key**——尚无逐 key 的 opt-out 或惰性 key 请求形状;在值都是 UI 量级的全量状态时可以接受,若某领域的值变大再重议。 +- **只有带 key 子集的读取才省略 key;尾页仍携带每个客户端可见 key,也不存在惰性逐值请求**——摘要只省略那些 `SessionProjectionMap` 条目带有 `SessionProjectionSummaryExcluded` 且注册声明了 `wire.summary: false` 的 key。该 opt-out 按 key 生效,不按值生效:没有任何读取会延后加载单个大值。 - **单元表是进程级的,因此 key 是否存在不能当作逐会话的能力信号**——任何 agent preset 注册的 key 都会出现在每个会话的快照里;客户端必须读值,不能把 key 缺席当作功能缺席。 - **主动驱动逐事件触达每个单元**——按构造开销很低(全量值规则与 state/view 引用闸门),但若出现热点路径,可加按单元的事件类型预过滤。 - **注册表 cell 只活在内存里**——重启后首次触达时靠折叠日志重建;挂载了 `dsh-session-projection-cache` 的组合改由持久行播种该折叠。 diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index ac6e63458f..b196567ad5 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -33,9 +33,21 @@ declare module '@deepseek-ai/cordis' { } } -import type { SessionProjectionMap, SessionProjectionStateMap } from './types.ts' +import type { + SessionProjectionMap, + SessionProjectionStateMap, + SessionProjectionViewValue, +} from './types.ts' -export type { SessionProjectionMap, SessionProjectionStateMap } from './types.ts' +export type { + SessionProjectionMap, + SessionProjectionStateMap, + SessionProjectionSummaryExcluded, + SessionProjectionSummaryExcludedKey, + SessionProjectionSummaryValues, + SessionProjectionViewValue, + SessionProjectionViewValues, +} from './types.ts' /** * One domain's state-driven computation unit: a pure synchronous fold plus @@ -72,7 +84,7 @@ export interface ProjectionDefinition< /** Client view. Omit for host-only units. */ wire?: K extends keyof SessionProjectionMap ? { /** Validates the wire payload before it leaves the host. */ - viewSchema: ZodType + viewSchema: ZodType> /** * State → wire payload (the read-side projection). The live drive keeps * the two latest raw results and compares them with `Object.is`; an @@ -81,7 +93,15 @@ export interface ProjectionDefinition< * @param state - the current state. * @returns the whole current value for this unit's key. */ - view(state: NoInfer): SessionProjectionMap[K] + view(state: NoInfer): SessionProjectionViewValue + /** + * Whether a Session summary carries this unit; omitted means `true`. + * Declare `false` on a key whose map entry carries the + * `SessionProjectionSummaryExcluded` mark: the runtime key set follows this + * field, while the client view type follows the mark. + * @default true + */ + summary?: boolean } : never /** * Persisted-cache invalidation version: bump whenever the serialized state fields or the @@ -104,6 +124,27 @@ export type ProjectionChangeListener = ( seq: SessionSeq, ) => void +/** + * One read's client-visible key selection: an explicit key list, or a derived + * set such as {@link SessionProjectionRegistry.summaryKeys}. A set is used as + * given; only a list is copied into one. + */ +export type ProjectionKeySelection = + | readonly Extract[] + | ReadonlySet> + +/** + * Narrow one key selection to the set a read loop consults. A caller that + * already holds a set (such as `summaryKeys()`) is served without a copy; only + * an explicit list allocates. + * @param keys - the caller's selection, or `undefined` for every key. + * @returns the selection as a set, or `undefined` when no selection was given. + */ +function selectedKeys(keys: ProjectionKeySelection | undefined): ReadonlySet | undefined { + if (keys === undefined) return undefined + return keys instanceof Set ? keys : new Set(keys) +} + /** * One consistent read cut over every registered client-visible unit for one session. * `asOfSeq` is the shared watermark — the seq of the last event every value @@ -142,7 +183,12 @@ interface ErasedDefinition { stateSchema: { parse(value: unknown): unknown } init(header: SessionHeader, inheritedEventCount: SessionLogOffset): unknown apply(state: unknown, event: SessionEvent): unknown - wire: { viewSchema: { parse(value: unknown): unknown }; view(state: unknown): unknown } | undefined + wire: { + viewSchema: { parse(value: unknown): unknown } + view(state: unknown): unknown + /** Whether a Session summary carries this unit (false when the unit declared `summary: false`). */ + summary: boolean + } | undefined stateVersion: number } @@ -198,6 +244,8 @@ function cursorBefore(offset: SessionLogOffset): SessionSeqCursor { */ export class SessionProjectionRegistry extends Service { private readonly registrations = new Map() + /** Stable summary key set; dropped whenever the key table changes. */ + private summaryKeySet: ReadonlySet> | undefined private readonly listeners = new Set() /** @@ -256,15 +304,20 @@ export class SessionProjectionRegistry extends Service { const wire = definition.wire as { viewSchema: ZodType view(state: S): unknown + summary?: boolean } | undefined const erased: ErasedDefinition = { key: definition.key, stateSchema: definition.stateSchema, init: (header, inheritedEventCount) => definition.init(header, inheritedEventCount), apply: (state, event) => definition.apply(state as S, event), + // The summary mark has no runtime representation: `wire.view` and + // `viewSchema` are already typed with the mark stripped, so this erase + // only widens the state type. `summary` carries the declared membership + // into the erased unit, where the key set reads it. wire: wire === undefined ? undefined - : { viewSchema: wire.viewSchema, view: state => wire.view(state as S) }, + : { viewSchema: wire.viewSchema, view: state => wire.view(state as S), summary: wire.summary !== false }, stateVersion: definition.stateVersion, } if (!Number.isSafeInteger(definition.stateVersion) || definition.stateVersion < 0) { @@ -275,10 +328,14 @@ export class SessionProjectionRegistry extends Service { const existing = this.registrations.get(key) if (existing === undefined) { this.registrations.set(key, { def: erased, cells: new WeakMap(), refs: 1 }) + this.summaryKeySet = undefined } else { if (existing.def.stateVersion !== erased.stateVersion) { throw new Error(`session projection key ${JSON.stringify(key)} is already registered at stateVersion ${String(existing.def.stateVersion)}; refusing to share it with stateVersion ${String(erased.stateVersion)}`) } + if (existing.def.wire?.summary !== erased.wire?.summary) { + throw new Error(`session projection key ${JSON.stringify(key)} is already registered with summary=${String(existing.def.wire?.summary)}; refusing to share it with summary=${String(erased.wire?.summary)}`) + } existing.refs += 1 } yield () => { @@ -286,7 +343,10 @@ export class SessionProjectionRegistry extends Service { /* v8 ignore next -- the disposer runs once per successful registration, so the entry it counted is still here */ if (live === undefined) return live.refs -= 1 - if (live.refs === 0) this.registrations.delete(key) + if (live.refs === 0) { + this.registrations.delete(key) + this.summaryKeySet = undefined + } } }.bind(this), 'sessionProjections.register()') // oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; keep the Cordis disposer so effects can adopt it. @@ -338,10 +398,10 @@ export class SessionProjectionRegistry extends Service { */ snapshot( session: Session, - keys?: readonly Extract[], + keys?: ProjectionKeySelection, ): ProjectionSnapshot { const values: Record = {} - const selected = keys === undefined ? undefined : new Set(keys) + const selected = selectedKeys(keys) this.materializeCells(session) for (const registration of this.registrations.values()) { if (registration.def.wire === undefined) continue @@ -362,11 +422,11 @@ export class SessionProjectionRegistry extends Service { */ cachedSnapshot( session: Session, - keys?: readonly Extract[], + keys?: ProjectionKeySelection, ): ProjectionSnapshot | undefined { const values: Record = {} let asOfSeq: SessionSeqCursor | undefined - const selected = keys === undefined ? undefined : new Set(keys) + const selected = selectedKeys(keys) for (const registration of this.registrations.values()) { if (registration.def.wire === undefined) continue if (selected !== undefined && !selected.has(registration.def.key)) continue @@ -380,6 +440,30 @@ export class SessionProjectionRegistry extends Service { return asOfSeq === undefined ? undefined : { asOfSeq, values } } + /** + * The keys a Session summary carries: every registered client-visible key + * whose unit does not declare `summary: false`. The returned set is stable — + * the same instance on every call — until a registration or its disposal + * changes the key table, so a consumer may hold it across rows. Callers pass + * it to a key-taking read face such as `cachedSnapshot(session, summaryKeys())` + * or `viewCheckpoint(rows, summaryKeys())`; those faces use the set as-is. + * @returns the current summary key set; do not mutate it. + */ + summaryKeys(): ReadonlySet> { + this.summaryKeySet ??= this.deriveSummaryKeys() + return this.summaryKeySet + } + + /** Rebuild the summary key set from the current registrations. */ + private deriveSummaryKeys(): ReadonlySet> { + const keys = new Set>() + for (const registration of this.registrations.values()) { + if (registration.def.wire === undefined || !registration.def.wire.summary) continue + keys.add(registration.def.key as Extract) + } + return keys + } + /** * State-level checkpoint of every persisted unit for one session, read * from the watermark cache (missing cells fold lazily over the in-memory @@ -448,10 +532,10 @@ export class SessionProjectionRegistry extends Service { */ viewCheckpoint( checkpoint: ProjectionCheckpoint, - keys?: readonly Extract[], + keys?: ProjectionKeySelection, ): Partial { const values: Record = {} - const selected = keys === undefined ? undefined : new Set(keys) + const selected = selectedKeys(keys) for (const registration of this.registrations.values()) { const def = registration.def if (def.wire === undefined) continue diff --git a/packages/session/session-projection/src/types.ts b/packages/session/session-projection/src/types.ts index 8373e4677b..ec5ef1d579 100644 --- a/packages/session/session-projection/src/types.ts +++ b/packages/session/session-projection/src/types.ts @@ -8,11 +8,36 @@ * @module @deepseek-ai/dsh-session-projection/types */ +/** + * Type-only marker of a `SessionProjectionMap` entry that a Session summary + * leaves out. The symbol never exists at runtime: a marked entry keeps its + * plain JSON value, `SessionProjectionViewValue` strips the mark in the wire + * and client view types, and the only runtime fact is the `summary: false` + * declared at registration. A marked key's registration declares + * `summary: false` so the runtime key set and the client view type agree, and + * `summaryKeys()` skips exactly those keys; the registration types do not + * enforce that pairing. + */ +declare const sessionProjectionSummaryExcluded: unique symbol + +/** + * One `SessionProjectionMap` entry excluded from Session summary payloads + * (the Session list row and any other summary read). The mark is type-only; + * `T` keeps its exact runtime value and wire representation. Wrap only the + * excluded contributor's own map entry, and place a nullable entry's `null` + * outside the wrapper (`SessionProjectionSummaryExcluded | null`) so + * the required symbol cannot absorb that member. + */ +export type SessionProjectionSummaryExcluded = T & { + readonly [sessionProjectionSummaryExcluded]: true +} + /** * The merge-extensible client projection table shared by wire blocks, client * cells, and React hooks. Domain packages merge their client-visible key here; * values are wire-JSON whole values. How a value is rendered is the slot * system's business, never this layer's. + * */ export interface SessionProjectionMap {} @@ -22,3 +47,34 @@ export interface SessionProjectionMap {} * Values must be plain JSON so the projection cache can persist them. */ export interface SessionProjectionStateMap {} + +/** + * One map entry's client view value: the summary mark stripped. The + * conditional distributes over a nullable entry's union, so a `| null` member + * written outside the wrapper survives unchanged. + */ +export type SessionProjectionViewValue = + T extends SessionProjectionSummaryExcluded ? Value : T + +/** Every client view value, keyed by projection key, with no summary mark. */ +export type SessionProjectionViewValues = { + [K in keyof SessionProjectionMap]: SessionProjectionViewValue +} + +/** + * Keys a Session summary omits: map entries whose non-nullish value carries + * the summary mark. Nullish members are ignored, so a nullable entry is still + * recognized when its `null` lives outside the wrapper. + */ +export type SessionProjectionSummaryExcludedKey = { + [K in keyof SessionProjectionMap]: + NonNullable extends SessionProjectionSummaryExcluded ? K : never +}[keyof SessionProjectionMap] + +/** + * The client values a Session summary carries: every client view value except + * {@link SessionProjectionSummaryExcludedKey}. A summary read returns these + * keys only; an opened Session's full snapshot keeps every view value. + */ +export type SessionProjectionSummaryValues = + Omit diff --git a/packages/session/session-projection/tests/summary.spec.ts b/packages/session/session-projection/tests/summary.spec.ts new file mode 100644 index 0000000000..af65cb58ab --- /dev/null +++ b/packages/session/session-projection/tests/summary.spec.ts @@ -0,0 +1,218 @@ +/** + * Session-summary key set: `summaryKeys()` derives the keys a summary read may + * carry from the live registrations (a registered wire key whose unit did not + * declare `summary: false`), and the existing key-taking read faces serve + * exactly that set. The complete faces stay complete: `viewCheckpoint` and + * `cachedSnapshot` serve every wire key when called without a key subset. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { z } from 'zod' +import SessionStore, { SessionId, SessionSeq } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import type { + ProjectionCheckpoint, + ProjectionDefinition, +} from '@deepseek-ai/dsh-session-projection' +import type { SessionProjectionSummaryExcluded } from '@deepseek-ai/dsh-session-projection/types' + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + 'test/summary-kept': { text: string } | null + 'test/summary-excluded': number + 'test/summary-excluded-nullable': string[] | null + } + interface SessionProjectionMap { + 'test/summary-kept': { text: string } | null + 'test/summary-excluded': SessionProjectionSummaryExcluded + 'test/summary-excluded-nullable': SessionProjectionSummaryExcluded | null + } +} + +const keptUnit = () => ({ + key: 'test/summary-kept', + stateSchema: z.union([z.object({ text: z.string() }), z.null()]), + init: () => null, + apply: (state: { text: string } | null, event) => (event.type === 'turn/start' + ? { text: `turn-${String(event.data.turn)}` } + : state), + wire: { + viewSchema: z.union([z.object({ text: z.string() }), z.null()]), + view: state => state, + }, + stateVersion: 1, +}) satisfies ProjectionDefinition<'test/summary-kept', { text: string } | null> + +const excludedUnit = () => ({ + key: 'test/summary-excluded', + stateSchema: z.number().int().nonnegative(), + init: () => 0, + apply: (state: number) => state + 1, + wire: { viewSchema: z.number().int().nonnegative(), view: state => state, summary: false }, + stateVersion: 1, +}) satisfies ProjectionDefinition<'test/summary-excluded', number> + +const excludedNullableUnit = () => ({ + key: 'test/summary-excluded-nullable', + stateSchema: z.array(z.string()).nullable(), + init: () => null, + apply: (state: string[] | null, event) => (event.type === 'turn/start' + ? [`turn-${String(event.data.turn)}`] + : state), + wire: { viewSchema: z.array(z.string()).nullable(), view: state => state, summary: false }, + stateVersion: 1, +}) satisfies ProjectionDefinition<'test/summary-excluded-nullable', string[] | null> + +async function mount(): Promise<{ ctx: Context; registry: SessionProjectionRegistry }> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + return { ctx, registry: ctx.sessionProjections } +} + +describe('session projection summary keys', () => { + it('derives the summary set from the registered units and their marks', async () => { + const { ctx, registry } = await mount() + try { + registry.register(keptUnit()) + registry.register(excludedUnit()) + registry.register(excludedNullableUnit()) + const keys = registry.summaryKeys() + expect([...keys]).toEqual(['test/summary-kept']) + expect(keys.has('test/summary-excluded')).toBe(false) + // The set is stable while the key table is unchanged. + expect(registry.summaryKeys()).toBe(keys) + } finally { + await ctx.fiber.dispose() + } + }) + + it('refuses a second registration of one key with a different summary membership', async () => { + const { ctx, registry } = await mount() + try { + registry.register(keptUnit()) + expect(() => registry.register({ + ...keptUnit(), + wire: { ...keptUnit().wire, summary: false }, + })).toThrow(/test\/summary-kept.*already registered with summary=true/) + } finally { + await ctx.fiber.dispose() + } + }) + + it('rebuilds the summary set only when a registration or its disposal changes the key table', async () => { + const { ctx, registry } = await mount() + try { + const empty = registry.summaryKeys() + expect([...empty]).toEqual([]) + const fiber = await ctx.plugin(Object.assign((hostCtx: Context) => { + hostCtx.sessionProjections.register(keptUnit()) + }, { inject: ['sessionProjections'] })) + const registered = registry.summaryKeys() + expect(registered).not.toBe(empty) + expect([...registered]).toEqual(['test/summary-kept']) + expect(registry.summaryKeys()).toBe(registered) + await fiber.dispose() + const disposed = registry.summaryKeys() + expect(disposed).not.toBe(registered) + expect([...disposed]).toEqual([]) + } finally { + await ctx.fiber.dispose() + } + }) + + it('serves a summary-shaped block through the key-taking checkpoint face', async () => { + const { ctx, registry } = await mount() + try { + registry.register(keptUnit()) + registry.register(excludedUnit()) + registry.register(excludedNullableUnit()) + const checkpoint: ProjectionCheckpoint = { + 'test/summary-kept': { ver: 1, seq: SessionSeq(3), val: { text: 'kept' } }, + 'test/summary-excluded': { ver: 1, seq: SessionSeq(4), val: 7 }, + 'test/summary-excluded-nullable': { ver: 1, seq: SessionSeq(5), val: ['a'] }, + } + + expect(registry.viewCheckpoint(checkpoint, registry.summaryKeys())).toEqual({ + 'test/summary-kept': { text: 'kept' }, + }) + // Without the key subset the complete face still serves the marked keys. + expect(registry.viewCheckpoint(checkpoint)).toEqual({ + 'test/summary-kept': { text: 'kept' }, + 'test/summary-excluded': 7, + 'test/summary-excluded-nullable': ['a'], + }) + } finally { + await ctx.fiber.dispose() + } + }) + + it('serves cached summary cells through the key-taking cached face', async () => { + const { ctx, registry } = await mount() + try { + registry.register(keptUnit()) + registry.register(excludedUnit()) + const session = ctx.sessions.create(SessionId('summary-cached'), { meta: { cwd: '/workspace' } }) + session.append('turn/start', { turn: 1 }) + + // The derived set is passed as a set: no key-list copy inside the read. + expect(registry.cachedSnapshot(session, registry.summaryKeys())?.values).toEqual({ + 'test/summary-kept': { text: 'turn-1' }, + }) + expect(registry.cachedSnapshot(session)?.values).toEqual({ + 'test/summary-kept': { text: 'turn-1' }, + 'test/summary-excluded': 1, + }) + } finally { + await ctx.fiber.dispose() + } + }) + + it('reports an empty summary set while the complete face still serves only marked units', async () => { + const { ctx, registry } = await mount() + try { + registry.register(excludedUnit()) + registry.register(excludedNullableUnit()) + const session = ctx.sessions.create(SessionId('summary-empty'), { meta: { cwd: '/workspace' } }) + session.append('turn/start', { turn: 1 }) + + expect([...registry.summaryKeys()]).toEqual([]) + expect(registry.cachedSnapshot(session, [])).toBeUndefined() + expect(registry.cachedSnapshot(session)?.values).toEqual({ + 'test/summary-excluded': 1, + 'test/summary-excluded-nullable': ['turn-1'], + }) + } finally { + await ctx.fiber.dispose() + } + }) + + it('keeps the marked nullable entry nullable through the mark', async () => { + const { ctx, registry } = await mount() + try { + registry.register(excludedNullableUnit()) + const before = ctx.sessions.create(SessionId('summary-nullable'), { meta: { cwd: '/workspace' } }) + expect(registry.snapshot(before).values['test/summary-excluded-nullable']).toBeNull() + } finally { + await ctx.fiber.dispose() + } + }) + + it('drops an unmaterialized cell and a version-mismatched checkpoint row', async () => { + const { ctx, registry } = await mount() + try { + registry.register(keptUnit()) + const session = ctx.sessions.create(SessionId('summary-unmaterialized'), { meta: { cwd: '/workspace' } }) + expect(registry.cachedSnapshot(session, registry.summaryKeys())?.values).toEqual({ + 'test/summary-kept': null, + }) + const checkpoint: ProjectionCheckpoint = { + 'test/summary-kept': { ver: 2, seq: SessionSeq(1), val: { text: 'stale' } }, + } + expect(registry.viewCheckpoint(checkpoint, registry.summaryKeys())).toEqual({}) + } finally { + await ctx.fiber.dispose() + } + }) +}) diff --git a/packages/session/session-stats/src/projection.ts b/packages/session/session-stats/src/projection.ts index e7d3f32377..48763db8aa 100644 --- a/packages/session/session-stats/src/projection.ts +++ b/packages/session/session-stats/src/projection.ts @@ -208,5 +208,6 @@ export const sessionStatsProjectionDefinition = { decodeMs: state.decodeMs, decodeTokens: state.decodeTokens, }), + summary: false, }, } satisfies ProjectionDefinition<'sessionStats', SessionStatsState> diff --git a/packages/session/session-stats/src/types.ts b/packages/session/session-stats/src/types.ts index e11a300b19..214bfa8225 100644 --- a/packages/session/session-stats/src/types.ts +++ b/packages/session/session-stats/src/types.ts @@ -8,6 +8,8 @@ * @module @deepseek-ai/dsh-session-stats/types */ +import type { SessionProjectionSummaryExcluded } from '@deepseek-ai/dsh-session-projection/types' + // Marks this file a module so the declaration below AUGMENTS the projection // table instead of declaring an ambient module. export {} @@ -40,7 +42,11 @@ export interface SessionStatsProjection { declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionMap { - /** Whole-log turn/step counts and wall times; see {@link SessionStatsProjection}. */ - sessionStats: SessionStatsProjection + /** + * Whole-log turn/step counts and wall times; see + * {@link SessionStatsProjection}. Excluded from Session summary payloads: + * no list row renders these figures. + */ + sessionStats: SessionProjectionSummaryExcluded } } diff --git a/packages/session/session-turn-outline/src/projection.ts b/packages/session/session-turn-outline/src/projection.ts index 992273e72a..bee3313265 100644 --- a/packages/session/session-turn-outline/src/projection.ts +++ b/packages/session/session-turn-outline/src/projection.ts @@ -133,5 +133,6 @@ export const turnOutlineProjectionDefinition = { wire: { viewSchema: turnOutlineEntriesSchema, view: state => state.turns, + summary: false, }, } satisfies ProjectionDefinition<'turnOutline', TurnOutlineState> diff --git a/packages/session/session-turn-outline/src/types.ts b/packages/session/session-turn-outline/src/types.ts index c4ccbcdab3..b6ffcea3dd 100644 --- a/packages/session/session-turn-outline/src/types.ts +++ b/packages/session/session-turn-outline/src/types.ts @@ -8,6 +8,7 @@ */ import type { SessionSeq } from '@deepseek-ai/dsh-session/types' +import type { SessionProjectionSummaryExcluded } from '@deepseek-ai/dsh-session-projection/types' export {} @@ -43,7 +44,12 @@ declare module '@deepseek-ai/dsh-session-projection/types' { turnOutline: TurnOutlineState } interface SessionProjectionMap { - /** Every started turn with its `turn/start` seq and bounded previews, strictly increasing by turn; see {@link TurnOutlineEntry}. */ - turnOutline: readonly TurnOutlineEntry[] + /** + * Every started turn with its `turn/start` seq and bounded previews, + * strictly increasing by turn; see {@link TurnOutlineEntry}. Excluded from + * Session summary payloads: the outline grows with the whole log and no + * list row renders it. + */ + turnOutline: SessionProjectionSummaryExcluded } } diff --git a/packages/todo/tool-todo/src/index.ts b/packages/todo/tool-todo/src/index.ts index f345045c93..8835e74614 100644 --- a/packages/todo/tool-todo/src/index.ts +++ b/packages/todo/tool-todo/src/index.ts @@ -129,7 +129,7 @@ export function apply(ctx: Context, config: Config): void { if (event.type === 'turn/start') return null return state }, - wire: { viewSchema: todosProjectionSchema, view: state => state }, + wire: { viewSchema: todosProjectionSchema, view: state => state, summary: false }, stateVersion: 2, }) ctx.tools.register(defineTool({ diff --git a/packages/todo/tool-todo/src/types.ts b/packages/todo/tool-todo/src/types.ts index 68d5649bb0..15f5dab88c 100644 --- a/packages/todo/tool-todo/src/types.ts +++ b/packages/todo/tool-todo/src/types.ts @@ -8,6 +8,8 @@ * @module @deepseek-ai/dsh-tool-todo/types */ +import type { SessionProjectionSummaryExcluded } from '@deepseek-ai/dsh-session-projection/types' + /** * One entry in an agent's todo list — the unit of the `todo/write` * whole-list snapshot declared by this package. @@ -41,7 +43,9 @@ declare module '@deepseek-ai/dsh-session-projection/types' { * The agent's current whole todo list (the latest `todo/write` snapshot), * or `null` before the first write. Whole-value rule: every `todo/write` * carries the complete replacement list, so the fold is last-wins. + * Excluded from Session summary payloads, with `null` kept outside the + * exclusion mark so the pre-first-write value stays expressible. */ - todos: TodoItem[] | null + todos: SessionProjectionSummaryExcluded | null } } diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index df1833ce5e..8a6da744be 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -831,6 +831,7 @@ export const LINK_MAP: Readonly> = { ProjectionDefinition: 'session-projection.md', SessionProjectionMap: 'session-projection.md', SessionProjectionStateMap: 'session-projection.md', + ProjectionKeySelection: 'session-projection.md', ProjectionChangeListener: 'session-projection.md', ProjectionSnapshot: 'session-projection.md', ProjectionCheckpoint: 'session-projection.md', @@ -868,6 +869,7 @@ export const FOUNDATION_TYPE_NAMES: ReadonlySet = new Set([ 'Record', 'Readonly', 'ReadonlyMap', + 'ReadonlySet', 'Request', 'Response', 'IncomingMessage', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 637c9d7bf5..39752bafd3 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -2481,6 +2481,36 @@ "doc": "docs/subsystems/boot.md", "symbol": "PendingModule", "source": "packages/boot/hmr/src/index.ts" + }, + { + "doc": "docs/subsystems/session-projection.md", + "symbol": "SessionProjectionSummaryExcluded", + "source": "packages/session/session-projection/src/types.ts" + }, + { + "doc": "docs/subsystems/session-projection.md", + "symbol": "SessionProjectionViewValue", + "source": "packages/session/session-projection/src/types.ts" + }, + { + "doc": "docs/subsystems/session-projection.md", + "symbol": "SessionProjectionViewValues", + "source": "packages/session/session-projection/src/types.ts" + }, + { + "doc": "docs/subsystems/session-projection.md", + "symbol": "SessionProjectionSummaryExcludedKey", + "source": "packages/session/session-projection/src/types.ts" + }, + { + "doc": "docs/subsystems/session-projection.md", + "symbol": "SessionProjectionSummaryValues", + "source": "packages/session/session-projection/src/types.ts" + }, + { + "doc": "docs/subsystems/session-projection.md", + "symbol": "ProjectionKeySelection", + "source": "packages/session/session-projection/src/index.ts" } ] } diff --git a/snapshots/session/cua-native/replay.override.json b/snapshots/session/cua-native/replay.override.json index 57e1373f80..2fefba26f5 100644 --- a/snapshots/session/cua-native/replay.override.json +++ b/snapshots/session/cua-native/replay.override.json @@ -212,7 +212,7 @@ "type": "tool-call", "id": "cua-native-4", "name": "cua", - "arguments": "{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-1\",\"elementIndex\":0}}" + "arguments": "{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"snapshotId\":\"fixture-snapshot-1\",\"elementIndex\":0,\"holdMs\":800}}" } }, { @@ -674,7 +674,7 @@ "type": "tool-call", "id": "cua-native-other-window", "name": "cua", - "arguments": "{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8,\"snapshotId\":\"fixture-snapshot-4\",\"x\":0,\"y\":0}}" + "arguments": "{\"method\":\"click\",\"params\":{\"appHandle\":\"fixture-app\",\"windowId\":8,\"snapshotId\":\"fixture-snapshot-4\",\"x\":0,\"y\":0,\"holdMs\":800}}" } }, { diff --git a/snapshots/session/cua-native/system-prompt.expected.md b/snapshots/session/cua-native/system-prompt.expected.md index c43ddf6dd8..82bccf01cb 100644 --- a/snapshots/session/cua-native/system-prompt.expected.md +++ b/snapshots/session/cua-native/system-prompt.expected.md @@ -57,7 +57,7 @@ App and Window methods: - getScreenshot(options?: { emit?: boolean }) -> CuaScreenshot - getAXStateAndScreenshot(options?) -> { state: string, screenshot: CuaScreenshot } - hover(target: number | [x, y], options?: { relative?: boolean }) -> { dispatched, verified, hoverRoute, systemPointerMoved } -- click(target: number | [x, y], options?: { mouseButton?: "left" | "right" | "middle", clickCount?: 1 | 2, modifiers?: Array<"command" | "control" | "option" | "shift" | "fn">, relative?: boolean }) -> action result +- click(target: number | [x, y], options?: { mouseButton?: "left" | "right" | "middle", clickCount?: 1 | 2, holdMs?: number, modifiers?: Array<"command" | "control" | "option" | "shift" | "fn">, relative?: boolean }) -> action result - setValue(elementIndex: number, value: string) -> action result - selectText(elementIndex: number, text: string, options?: { prefix?: string, suffix?: string, selectionType?: "text" | "cursor_before" | "cursor_after" }) -> action result - performSecondaryAction(elementIndex: number, action: string) -> action result @@ -70,4 +70,6 @@ Await operations sequentially. After input, read fresh state to verify the effec Hover first uses advertised AX alternate UI without moving the system cursor, preserving revealed descendants until the pointer target leaves their region. When unavailable, it moves the visible system cursor without pressing a button; that route requires the target point to be unobscured in the foreground app. FOREGROUND_REQUIRED requires explicit activation or raising and a fresh observation before retrying. Returning, AX reads and screenshots do not restore the previous point. Wait for the application's hover delay, observe, then hover into the revealed controls before clicking. Use successive hover calls for intermediate points. The result reports hoverRoute and systemPointerMoved. Other input or application changes can interrupt the interaction; app objects do not reserve applications between calls. -Use selectText only on controls advertising selectionWritable. It selects a unique literal occurrence, or places the cursor before/after it; prefix and suffix must be immediately adjacent. AMBIGUOUS_TEXT requires more context; TEXT_NOT_FOUND or SELECTION_CHANGED requires a fresh observation before deciding on input. Left clicks support single/double clicks; right and middle clicks support single clicks. Modified clicks use pointer input. Keypad names such as KP_0 and KP_Enter retain their physical key identity. Window objects expose observation and pointer/AX actions; keyboard input remains app-scoped and follows current focus. Scroll deltas are integer lines from -10 to 10; positive values reveal content above or left. Paste delivers plain text or HTML through the application's enabled standard Paste menu command. Paste follows the target app's focused text input. Foreground requirements vary by application; for example, ChatGPT/Codex was observed to accept background Paste without reading clipboard data. PASTE_NOT_CONSUMED means the command was dispatched but no clipboard read was observed before the configured deadline. Inspect the target contents, explicitly activate the app and focus the intended input, then observe again before deciding whether to retry; a delayed paste may still run. Original clipboard formats are restored if the clipboard still belongs to this paste; a later copy is preserved. Unsupported Paste commands fail without typing fallback. CLIPBOARD_CHANGED means another writer intervened; observe before deciding on further input. Browser applications can be controlled with getApp like other native apps; browser tabs are unavailable. Installed-app search covers standard macOS application directories; use an absolute application path for another location. Launch does not restore a window or document, and cancellation may leave an application already started. The execution host must install the object API and own variable persistence; this plugin does not preload a cua global. In persistent PTC, use the host-provided initialization snippet below once. Its installation-owned file URL works independently of the project directory. Retain app objects between calls. Observation methods emit by default and also return their value. Use void await app.getAXState() to avoid a duplicate REPL completion, or pass emit:false and print the returned text once. End screenshot calls with void await app.getScreenshot() or void await app.getAXStateAndScreenshot() so image bytes are not serialized as the program completion value. +Use selectText only on controls advertising selectionWritable. It selects a unique literal occurrence, or places the cursor before/after it; prefix and suffix must be immediately adjacent. AMBIGUOUS_TEXT requires more context; TEXT_NOT_FOUND or SELECTION_CHANGED requires a fresh observation before deciding on input. Left clicks support single/double clicks; right and middle clicks support single clicks. Modified clicks use pointer input. Optional holdMs is a nonnegative integer in milliseconds for each press, including each press of a double-click. Zero releases without an additional hold. The configured maximum is stated below. It uses pointer input even for AX targets; omission preserves ordinary clicks. The operation deadline still applies, and cancellation or timeout releases the held button. Keypad names such as KP_0 and KP_Enter retain their physical key identity. Window objects expose observation and pointer/AX actions; keyboard input remains app-scoped and follows current focus. Scroll deltas are integer lines from -10 to 10; positive values reveal content above or left. Paste delivers plain text or HTML through the application's enabled standard Paste menu command. Paste follows the target app's focused text input. Foreground requirements vary by application; for example, ChatGPT/Codex was observed to accept background Paste without reading clipboard data. PASTE_NOT_CONSUMED means the command was dispatched but no clipboard read was observed before the configured deadline. Inspect the target contents, explicitly activate the app and focus the intended input, then observe again before deciding whether to retry; a delayed paste may still run. Original clipboard formats are restored if the clipboard still belongs to this paste; a later copy is preserved. Unsupported Paste commands fail without typing fallback. CLIPBOARD_CHANGED means another writer intervened; observe before deciding on further input. Browser applications can be controlled with getApp like other native apps; browser tabs are unavailable. Installed-app search covers standard macOS application directories; use an absolute application path for another location. Launch does not restore a window or document, and cancellation may leave an application already started. The execution host must install the object API and own variable persistence; this plugin does not preload a cua global. In persistent PTC, use the host-provided initialization snippet below once. Its installation-owned file URL works independently of the project directory. Retain app objects between calls. Observation methods emit by default and also return their value. Use void await app.getAXState() to avoid a duplicate REPL completion, or pass emit:false and print the returned text once. End screenshot calls with void await app.getScreenshot() or void await app.getAXStateAndScreenshot() so image bytes are not serialized as the program completion value. + +Maximum click holdMs: 5000 ms. diff --git a/snapshots/session/cua-native/tool-schemas.expected.json b/snapshots/session/cua-native/tool-schemas.expected.json index 970dd64091..e9e550b1e6 100644 --- a/snapshots/session/cua-native/tool-schemas.expected.json +++ b/snapshots/session/cua-native/tool-schemas.expected.json @@ -468,6 +468,12 @@ "const": 2 } ] + }, + "holdMs": { + "description": "Milliseconds between each mouse press and release; omitted for an ordinary click.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 } }, "required": [ @@ -538,6 +544,12 @@ "const": 2 } ] + }, + "holdMs": { + "description": "Milliseconds between each mouse press and release; omitted for an ordinary click.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 } }, "required": [ @@ -611,6 +623,12 @@ "const": 2 } ] + }, + "holdMs": { + "description": "Milliseconds between each mouse press and release; omitted for an ordinary click.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 } }, "required": [ diff --git a/snapshots/session/cua-ptc/system-prompt.expected.md b/snapshots/session/cua-ptc/system-prompt.expected.md index 1836fffaed..3c9c10466b 100644 --- a/snapshots/session/cua-ptc/system-prompt.expected.md +++ b/snapshots/session/cua-ptc/system-prompt.expected.md @@ -59,7 +59,7 @@ App and Window methods: - getScreenshot(options?: { emit?: boolean }) -> CuaScreenshot - getAXStateAndScreenshot(options?) -> { state: string, screenshot: CuaScreenshot } - hover(target: number | [x, y], options?: { relative?: boolean }) -> { dispatched, verified, hoverRoute, systemPointerMoved } -- click(target: number | [x, y], options?: { mouseButton?: "left" | "right" | "middle", clickCount?: 1 | 2, modifiers?: Array<"command" | "control" | "option" | "shift" | "fn">, relative?: boolean }) -> action result +- click(target: number | [x, y], options?: { mouseButton?: "left" | "right" | "middle", clickCount?: 1 | 2, holdMs?: number, modifiers?: Array<"command" | "control" | "option" | "shift" | "fn">, relative?: boolean }) -> action result - setValue(elementIndex: number, value: string) -> action result - selectText(elementIndex: number, text: string, options?: { prefix?: string, suffix?: string, selectionType?: "text" | "cursor_before" | "cursor_after" }) -> action result - performSecondaryAction(elementIndex: number, action: string) -> action result @@ -72,7 +72,9 @@ Await operations sequentially. After input, read fresh state to verify the effec Hover first uses advertised AX alternate UI without moving the system cursor, preserving revealed descendants until the pointer target leaves their region. When unavailable, it moves the visible system cursor without pressing a button; that route requires the target point to be unobscured in the foreground app. FOREGROUND_REQUIRED requires explicit activation or raising and a fresh observation before retrying. Returning, AX reads and screenshots do not restore the previous point. Wait for the application's hover delay, observe, then hover into the revealed controls before clicking. Use successive hover calls for intermediate points. The result reports hoverRoute and systemPointerMoved. Other input or application changes can interrupt the interaction; app objects do not reserve applications between calls. -Use selectText only on controls advertising selectionWritable. It selects a unique literal occurrence, or places the cursor before/after it; prefix and suffix must be immediately adjacent. AMBIGUOUS_TEXT requires more context; TEXT_NOT_FOUND or SELECTION_CHANGED requires a fresh observation before deciding on input. Left clicks support single/double clicks; right and middle clicks support single clicks. Modified clicks use pointer input. Keypad names such as KP_0 and KP_Enter retain their physical key identity. Window objects expose observation and pointer/AX actions; keyboard input remains app-scoped and follows current focus. Scroll deltas are integer lines from -10 to 10; positive values reveal content above or left. Paste delivers plain text or HTML through the application's enabled standard Paste menu command. Paste follows the target app's focused text input. Foreground requirements vary by application; for example, ChatGPT/Codex was observed to accept background Paste without reading clipboard data. PASTE_NOT_CONSUMED means the command was dispatched but no clipboard read was observed before the configured deadline. Inspect the target contents, explicitly activate the app and focus the intended input, then observe again before deciding whether to retry; a delayed paste may still run. Original clipboard formats are restored if the clipboard still belongs to this paste; a later copy is preserved. Unsupported Paste commands fail without typing fallback. CLIPBOARD_CHANGED means another writer intervened; observe before deciding on further input. Browser applications can be controlled with getApp like other native apps; browser tabs are unavailable. Installed-app search covers standard macOS application directories; use an absolute application path for another location. Launch does not restore a window or document, and cancellation may leave an application already started. The execution host must install the object API and own variable persistence; this plugin does not preload a cua global. In persistent PTC, use the host-provided initialization snippet below once. Its installation-owned file URL works independently of the project directory. Retain app objects between calls. Observation methods emit by default and also return their value. Use void await app.getAXState() to avoid a duplicate REPL completion, or pass emit:false and print the returned text once. End screenshot calls with void await app.getScreenshot() or void await app.getAXStateAndScreenshot() so image bytes are not serialized as the program completion value. +Use selectText only on controls advertising selectionWritable. It selects a unique literal occurrence, or places the cursor before/after it; prefix and suffix must be immediately adjacent. AMBIGUOUS_TEXT requires more context; TEXT_NOT_FOUND or SELECTION_CHANGED requires a fresh observation before deciding on input. Left clicks support single/double clicks; right and middle clicks support single clicks. Modified clicks use pointer input. Optional holdMs is a nonnegative integer in milliseconds for each press, including each press of a double-click. Zero releases without an additional hold. The configured maximum is stated below. It uses pointer input even for AX targets; omission preserves ordinary clicks. The operation deadline still applies, and cancellation or timeout releases the held button. Keypad names such as KP_0 and KP_Enter retain their physical key identity. Window objects expose observation and pointer/AX actions; keyboard input remains app-scoped and follows current focus. Scroll deltas are integer lines from -10 to 10; positive values reveal content above or left. Paste delivers plain text or HTML through the application's enabled standard Paste menu command. Paste follows the target app's focused text input. Foreground requirements vary by application; for example, ChatGPT/Codex was observed to accept background Paste without reading clipboard data. PASTE_NOT_CONSUMED means the command was dispatched but no clipboard read was observed before the configured deadline. Inspect the target contents, explicitly activate the app and focus the intended input, then observe again before deciding whether to retry; a delayed paste may still run. Original clipboard formats are restored if the clipboard still belongs to this paste; a later copy is preserved. Unsupported Paste commands fail without typing fallback. CLIPBOARD_CHANGED means another writer intervened; observe before deciding on further input. Browser applications can be controlled with getApp like other native apps; browser tabs are unavailable. Installed-app search covers standard macOS application directories; use an absolute application path for another location. Launch does not restore a window or document, and cancellation may leave an application already started. The execution host must install the object API and own variable persistence; this plugin does not preload a cua global. In persistent PTC, use the host-provided initialization snippet below once. Its installation-owned file URL works independently of the project directory. Retain app objects between calls. Observation methods emit by default and also return their value. Use void await app.getAXState() to avoid a duplicate REPL completion, or pass emit:false and print the returned text once. End screenshot calls with void await app.getScreenshot() or void await app.getAXStateAndScreenshot() so image bytes are not serialized as the program completion value. + +Maximum click holdMs: 5000 ms. ## Writing code for run_code diff --git a/snapshots/web/goal-multi-turn-actions/ui.expected.md b/snapshots/web/goal-multi-turn-actions/ui.expected.md index 505a067cbb..5256392267 100644 --- a/snapshots/web/goal-multi-turn-actions/ui.expected.md +++ b/snapshots/web/goal-multi-turn-actions/ui.expected.md @@ -40,6 +40,26 @@ - time: {{clock}} - status: Completed - button "Completed in {{duration}}" +- paragraph: + - strong: Turn 2 / 2 + - text: — 随机选中的包: + - code: packages/llm/token-meter + - text: 📦 +- text: Code block +- button "Wrap lines" [pressed] +- button "Copy" +- code: token-meter ├── README.md ├── package.json ├── src │ └── index.ts └── tests └── token-meter.spec.ts +- text: Code block +- button "Wrap lines" [pressed] +- button "Copy" +- code: "token-meter/ ├── README.md # 包说明文档 ├── package.json # 包元数据与依赖声明 ├── src/ │ └── index.ts # 入口,导出公共 API(token 计量逻辑) └── tests/ └── token-meter.spec.ts # 单元测试" +- paragraph: + - text: 两个 turn 都完成了(turn 1: + - code: session-reference + - text: ,turn 2: + - code: token-meter + - text: )。这一轮到此结束。 +- paragraph: 两个 turn 均已完成,目标达成,标记 goal 为完成。 - paragraph: 两个 turn 都完成了 ✅ - paragraph: - strong: 总结: diff --git a/snapshots/web/plan-review/approved.expected.md b/snapshots/web/plan-review/approved.expected.md index bab8040ae6..a9c2ff2a1d 100644 --- a/snapshots/web/plan-review/approved.expected.md +++ b/snapshots/web/plan-review/approved.expected.md @@ -9,6 +9,10 @@ - button "Copy" - status: Completed - button "Completed in {{duration}}" +- paragraph: + - text: Since the user has explicitly asked me not to read or write any files and to go straight to planning, I'll proceed with + - code: exit_plan_mode + - text: . - paragraph: DONE - 'button "Open plan: Add `--greeting` flag to CLI"': "Add `--greeting` flag to CLI Plan · Markdown Open" - button "Copy" diff --git a/snapshots/web/turn-tail-actions/completed.expected.md b/snapshots/web/turn-tail-actions/completed.expected.md index 5d309edf46..f36f05d886 100644 --- a/snapshots/web/turn-tail-actions/completed.expected.md +++ b/snapshots/web/turn-tail-actions/completed.expected.md @@ -10,6 +10,7 @@ - button "Copy" - status: Completed - button "Completed in {{duration}}" +- paragraph: Reading the workspace now. - paragraph: DONE - button "Copy" - button "Good response" diff --git a/snapshots/web/turn-tail-actions/usage-expanded.expected.md b/snapshots/web/turn-tail-actions/usage-expanded.expected.md index 791695b15c..076114e666 100644 --- a/snapshots/web/turn-tail-actions/usage-expanded.expected.md +++ b/snapshots/web/turn-tail-actions/usage-expanded.expected.md @@ -10,6 +10,7 @@ - button "Copy" - status: Completed - button "Completed in {{duration}}" +- paragraph: Reading the workspace now. - paragraph: DONE - button "Copy" - button "Good response"